[tl;dr – Use ActiveSupport::Concern with modules in your Rails tests to reuse behavior. Or read the longer discussion and learn more about what’s going on behind the scenes.]
After writing a lot of BDD tests using Rspec and Cucumber in my Rails projects, I decided on a new approach for a new project: use the default Rails 4 test framework, Minitest. Duh.
Some consider Minitest “less readable.” I don’t really agree, and despite a lot of nice features, there’s something to be said for writing plain old Ruby vs. learning and remembering DSLs for both Rspec and Cucumber.
Committed to simple Ruby for tests, I wanted to use simple Ruby modules to share test behavior among different tests, because that’s the simple Ruby way to do it.
Setup
Create a new support/
directory to keep your modules in your project’s test/
directory. You could also call it “shared”, or “modules” or whatever makes sense to you.
Next, open up test/test_helper.rb
and add a single line to require all files in your new directory.
Dir[Rails.root.join("test/support/**/*")].each { |f| require f }
The test code
Here are three tests we want to reuse in a couple places; they’re checking to make sure an object implements a particular interface. Normally, these tests are defined inside an ActiveSupport::TestCase
class, but we take Minitest’s own suggestion of putting these common tests in a shared module:
module EdibleInterfaceTest
test "responds to tasty?" do
assert_respond_to(@object, :tasty?)
end
test "responds to edible?" do
assert_respond_to(@object, :edible?)
end
test "responds to calories" do
assert_respond_to(@object, :calories)
end
end
Note the syntax test 'test description' do ... end
. That’s a Rails helper for defining Minitest methods in a more readable way. Out of the box, you define tests with Minitest like any Ruby method, but with the format def test_[your_test_name]; end
.
Now we should just be able to include our module in a test, right? For example, we want to make sure an object-under-test, Chicken
, implements the Edible
methods.
require 'test_helper'
class ChickenTest < ActiveSupport::TestCase
include EdibleInterfaceTest
def setup
@supplier = @object = Chicken.new
end
end
We could also use these shared tests in, say, a Donut
test, or a Possum
test (gross). If you’re hip to duck typing, this method of composition shouldn’t be terribly mind-blowing.
Running the tests: Asplosion!
Running the test, however, we hit a snag:
./bin/rake test TEST=test/models/chicken_test.rb
rake aborted!
ArgumentError: wrong number of arguments (1 for 2)
/Users/jason/code/myproject/test/support/edible_interface_test.rb:5:in `test'
I feign surprise and ask rhetorically, “What happened?!”
Remember the test()
helper? It’s defined as a class method on ActiveSupport::TestCase
, so therefore we can only call it within the scope of this class. And in Ruby, the code in module and class definitions is executed immediately, not when the module gets included.
So the call to test()
happens within the scope of EdibleInterfaceTest
, not ActiveSupport::TestCase
, and therefore the implicit receiver of this call is self
, i.e. the module object. Under normal conditions, we’d get a NoMethodError
result from this, letting us know we called a class method that’s not there. So why did we see ArgumentError: wrong number of arguments (1 for 2)
? That’s confusing.
Turns out that Module
inherits Kernel#test
, and it has nothing to do with our Rails helper or Minitest. This explains the confusing ArgumentError
: we were accidentally calling a completely different inherited method.
Fixing it
So we’ve made a fundamental mistake understanding how Ruby modules work, compounded by a confusing error message. No worries: this stuff isn’t always written on the wall. (Yes, I’ve screwed this up myself.)
The solution involves us opening the ActiveSupport::TestCase
class when we include our module behavior, so that our test cases can run in the proper scope. There’s a common pattern for this:
module M
def self.included(base)
base.extend ClassMethods
base.class_eval do
# code to run in the class scope
end
end
module ClassMethods
...
end
end
And wouldn’t you know, there’s a handy shortcut for implementing this in Rails with the ActiveSupport::Concern construct. With this, we get a simple included
block helper to run code in the scope of the including class.
Running the tests again: Success!
So the update to our module is simple:
module EdibleInterfaceTest
extend ActiveSupport::Concern
included do
test "responds to tasty?" do
assert_respond_to(@object, :tasty?)
end
test "responds to edible?" do
assert_respond_to(@object, :edible?)
end
test "responds to calories" do
assert_respond_to(@object, :calories)
end
end
end
These three lines are all we need. Everything in the included()
block is run within whatever class is including the module. And nothing needs to change in the test definitions, we just include EdibleInterfaceTest
wherever we want the test to run, and call it a day. Simple!
./bin/rake test TEST=test/models/chicken_test.rb
# Running:
..
Fabulous run in 0.368603s, 5.4259 runs/s, 2.7129 assertions/s.
2 runs, 3 assertions, 0 failures, 0 errors, 0 skips
And there we have working tests with shared modules. Neet.
Further reading
Metaprogramming Ruby helped me learn a lot about, well, just that. In fact, it should probably be required reading for anyone working with Ruby on a regular basis, if not simply to answer the occasional question, “What the hell am I looking at?”
There are many of these patterns used all over Ruby and being able to identify them will, at the very least, help you debug, extend and demystify your code.