Skip to content

Instantly share code, notes, and snippets.

@ordinaryzelig
Last active December 10, 2022 13:34
Show Gist options
  • Save ordinaryzelig/2032303 to your computer and use it in GitHub Desktop.
Save ordinaryzelig/2032303 to your computer and use it in GitHub Desktop.
How to write MiniTest::Spec expectations

I'm a fan of MiniTest::Spec. It strikes a nice balance between the simplicity of TestUnit and the readable syntax of RSpec. When I first switched from RSpec to MiniTest::Spec, one thing I was worried I would miss was the ability to add matchers. (A note in terminology: "matchers" in MiniTest::Spec refer to something completely different than "matchers" in RSpec. I won't get into it, but from now on, let's use the proper term: "expectations").

Understanding MiniTest::Expectations

Let's take a look in the code (I'm specifically referring to the gem, not the standard library that's built into Ruby 1.9):

# minitest/spec.rb

module MiniTest::Expectations
  # ...

  ##
  # See MiniTest::Assertions#assert_instance_of
  #
  #    obj.must_be_instance_of klass
  #
  # :method: must_be_instance_of

  infect_an_assertion :assert_instance_of, :must_be_instance_of

  # ...
end

That entire module is filled with almost nothing but one-line "definitions". They each give a very brief description and instructions to see the related assertion. One great thing about MiniTest::Spec is that it's built right on top of MiniTest::Unit, which is very simple and therefor very fast. infect_an_assertion simply calls the assertion and passes the arguments. For example, from the above definition:

'foobar'.must_be_instance_of String calls assert_instance_of String, 'foobar'.

Notice the order of 'foobar' and String in the assertion as opposed to the expectation. See how they're flipped? The convention for the order of arguments in an assertion is expected value goes first, then actual value. With expectations, it's the opposite:

assert_equal 'expected', 'actual' and 'actual'.must_equal 'expected'

Let's look at another example:

infect_an_assertion :assert_includes, :must_include, :reverse

assert_includes [1, 2, 3], 1 is the same as [1, 2, 3].must_include 1. But notice that the order of the arguments isn't flipped. That's why the :reverse argument above is needed.

Similarly, another example of when it's necessary not to flip the arguments is when there is only 1 argument. For example:

infect_an_assertion :assert_empty, :must_be_empty, :unary

The assertion is assert_empty [], and the expectation is [].must_be_empty. infect_an_assertion splats (*args) the arguments, so the lack of arguments to flip doesn't mess up anything. But we do need to flip, nevertheless. The :unary argument does this for us. It does exactly the same thing the :reverse argument does. In fact, anything other than nil or false will work, but it's good to be descriptive.

Writing our own custom assertion/expectation

Now that we understand how MiniTest::Expectations uses infect_an_assertion to define expectations, let's write our own custom assertions and corresponding expectations.

require 'minitest/autorun'

module MiniTest::Assertions
  def assert_equals_rounded(rounded, decimal)
    assert rounded == decimal.round, "Expected #{decimal} to round to #{rounded}"
  end

  def assert_palindrome(string)
    assert string == string.reverse, "Expected #{string} to read the same way backwards and forwards"
  end

  def assert_default(hash, default_value)
    assert default_value == hash.default, "Expected #{default_value} to be default value for hash but was #{hash.default.inspect}"
  end
end

Numeric.infect_an_assertion :assert_equals_rounded, :must_round_to
String.infect_an_assertion :assert_palindrome, :must_be_palindrome, :only_one_argument
Hash.infect_an_assertion   :assert_default, :must_default_to, :do_not_flip

describe 'custom expectations' do

  describe '#must_round_to' do

    it 'rounds correctly for Float' do
      1.2.must_round_to 1
    end

    it 'rounds correctly for Fixnum' do
      1.must_round_to 1
    end

    it 'catches failures' do
      proc { 1.5.must_round_to 42 }.must_raise(MiniTest::Assertion)
    end

  end

  describe '#must_be_palindrome' do

    it 'reads a string backwards and forwards' do
      'racecar'.must_be_palindrome
    end

    it 'catches failures' do
      proc { 'kriss kross'.must_be_palindrome }.must_raise(MiniTest::Assertion)
    end

    it 'only works for Strings' do
      proc { 1.must_be_palindrome }.must_raise(NoMethodError)
    end

  end

  describe '#must_default_to' do

    it 'compares the default value of a hash' do
      hash = Hash.new(42)
      hash.must_default_to 42
    end

    it 'catches failures' do
      proc { {}.must_default_to 42 }.must_raise(MiniTest::Assertion)
    end

    it 'only works for Hashes' do
      proc { 1.must_default_to 2 }.must_raise(NoMethodError)
    end

  end

end

Notice that I was specific about what I used to call infect_an_assertion. infect_an_assertion is a method defined on Module, which means we can call it on just about anything. I was specific about String and Hash because I only want those expectations to be defined on instances of those classes. I defined must_round_to on Numeric because both Integer and Float (among others) respond to round.

The code above is completely executable. It works with Ruby built-in MiniTest too. Play around with it.

@SixArm
Copy link

SixArm commented May 3, 2012

Sweet, thank you for this. I'm converting a large Rails app to minitest spec and it's going great. Your explanation will help.

@bscott
Copy link

bscott commented Apr 6, 2013

Awesome read!

@matt8754
Copy link

Nice.I really like it.

@ebouchut
Copy link

Used it today, thanks for the useful post.

@svoop
Copy link

svoop commented Apr 11, 2018

@ordinaryzelig

While correct in the introduction, the examples appears to contain the wrong arguments on some of the infect methods:

  • :only_one_argument should be :unary
  • :do_not_flip should be :reverse

(I know that any other value than nil or false will work, but for the sake of consistency, :unary and :reverse are the better choice.)

@FilBot3
Copy link

FilBot3 commented Sep 16, 2018

Still relevant today.

@subimage
Copy link

Appreciate this, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment