DRYing up Minitest tests with metaprogramming
Aug 18 2014
I’ve recently been doing a lot of Rails work with test-driven development (naturally), and I’ve found it’s very easy to get a hell of a lot of duplication in Minitest tests.
For instance, if for some reason I wanted to test that my User model responds to various method calls, I could end up with code like this:
class UserTest < ActiveSupport::TestCase
def test_user_responds_to_first_name
assert User.new.respond_to?(:first_name),
'Users should respond to first_name'
end
def test_user_responds_to_last_name
assert User.new.respond_to?(:last_name),
'Users should respond to last_name'
end
def test_user_responds_to_email
assert User.new.respond_to?(:email),
'Users should respond to email'
end
# and so on...
end
The problem here is that code like this heavily violates the D.R.Y. principle- Don’t repeat yourself. Repetitive code is a pain to work with and maintain; If we need to make a slight change to our testing process here we have to perform the same edit in every place this snippet crops up in our code, which could take quite some time with a comprehensive test suite. Worse still, it’s much more likely that we will make a mistake if we have to make an edit twenty times, rather than just once.
Iterate and assert
One obvious solution would be to load the name symbols of the methods we want to check into an array, and then iterate though this array, asserting the model responds to each symbol in turn. It might look something like this:
class UserTest < ActiveSupport::TestCase
def test_user_responds_to_various_methods
%i(
first_name last_name email admin password created_at updated_at
password_confirmation remember_token collection_id authenticate
).each do |method|
assert User.new.respond_to?(method),
"Users should respond to #{method}"
end
end
end
That’s better. By adding a bit of basic logic we’ve checked that a whole bunch of methods exist, and we’ve managed to not repeat ourselves at all.
… I still don’t like this…
The problem here is that we’ve got a lot of assertions in one test, so if an
assertion fails, the whole test fails, and the methods later in the array don’t
get tested at all. If this test fails on the first_name
assertion, is
first_name
the only method that we’ve a problem with? We’ve no way of telling
without either adding more tests, or checking manually.
Iterate and metaprogram
What I really want is an automated way of creating one test per method to be checked. How can we do this? Metaprogramming, of course!
class UserTest < ActiveSupport::TestCase
%i(
first_name last_name email admin password created_at updated_at
password_confirmation remember_token collection_id authenticate
).each do |method|
test_name = "test_user_responds_to_#{method}".to_s
define_method(test_name) do
assert User.new.respond_to?(method),
"Users should respond to #{method}"
end
end
end
Here we’re using define_method
, which (surprise surprise) defines a method on
the class it’s called from. If we add an array of method names, and some string
interpolation, and we can create a new test for each method that we wish to
test on the model!
You don’t have to just use this technique for checking whether model responds a certain method call, anywhere were you’ve got multiple near-identical test methods could be a good place to use metaprogramming in this way. For example, checking whether a non-admin user can successfully perform action X on a controller.
So there we have it, DRY-er tests that don’t hide the details from us. And because we’ve done it with metaprogramming, we get Rubyist street cred. Banging.
Cheers, Louis