Share behavior between your Rails tests using modules

[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

Amazon.com: Metaprogramming Ruby: Program Like the Ruby ProsMetaprogramming 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.

  • This is exactly what I was looking for and I bumped into the same error. How would you pass parameters to the module? Through instance variables in the setup block maybe?

  • Pollard5150

    This was a great help, thank you!

  • “I feign surprise and ask rhetorically, “What happened?!””

    It’s rare a rails tutorial has me audibly laughing

  • Ingvar Nikiforov

    Hey,
    I’m sorry to write this but it is very hard to read article with this font. It is too thin.
    I literally had to run Safari and use Reader View to be able to consume content.