Assert Multiple Differences in Minitest with assert_differences

This post was heavily inspired by Malcolm Locke's article at Wholemeal. However, that article is fairly dated and I prefer the syntax of my implementation.

assert_differences in Rails

There have been many times in my coding career that I've needed to test that multiple values change during an event.

Maybe you want to check that a User, their Settings, and Posts all get deleted when a user removes their account. Technically you should be testing each of these individually, but in practice it's generally acceptable to make make multiple assertions per test as long as:

  1. The assertions are closely related in function
  2. The assertions are all simple
  3. You're consistent across your code base

Anyway, let's get to it

The conventional method for testing multiple differences is something like:

assert_difference('User.count', -1) do
  assert_difference('GoodbyeMailerJob.size', +1) do
    assert_difference('Post.count', -5) do
      # Logic...
    end
  end
end

Which is fine, but unwieldy at best. It's also clearly not very DRY. I think this can be improved.

Enter: assert_differences

Here's an example of the syntax:

assert_differences(['User.count', 'Post.count'], by: [-1, -5]) do
  # Logic...
end

# Or...

assert_differences('User.count', by: -1) do
  # Logic... (although you should be using assert_difference in this case)
end

# Or...

assert_differences(-> { User.count }, by: -1) do
  # I can do lambdas too!
end

# Or...

assert_differences(['User.count'], by: [-5], message: 'No can do, kiddo') do
  # Logic which shows your message on failure
end

I think you can see that assert_differences has the potential to DRY things up greatly. I also find it subjectively much easier to follow.

I've shown this to many developers on several forums and the response has been very positive. I'd love to see this baked into Rails core, but the Rails team seems hesitant to add this new method.

Here's how it's done:

(Note that this uses required keyword arguments, so Ruby 2.1+ is required. If you need to support Ruby 2.0, change by: to by: [] in the first line)

# Drop into test_helper.rb or similar
def assert_differences(expression_array, by:, message: nil, &block)
  expressions = Array(expression_array)
  change_by = Array(by).map(&:to_i)

  if expressions.size != change_by.size
    raise ArgumentError, 'Each expression must have a corresponding value to change by'
  end

  exps = expressions.map.with_index do |e, i|
    callable = e.respond_to?(:call) ? e : lambda { eval(e, block.binding) }
    {
      callable: callable,
      before: callable.call,
      expected_change: change_by[i]
    }
  end

  retval = yield

  expressions.zip(exps).each do |code, e|
    difference = e[:expected_change]
    error      = "#{code.inspect} didn't change by #{difference}"
    error      = "#{message}.\n#{error}" if message
    assert_equal(e[:before] + difference, e[:callable].call, error)
  end

  retval
end

That's it! Here's the same thing in Gist form. If you have any comments on this, reach out on Twitter or reddit. This is my first article so I'd love feedback.