Getting to Know the Ruby Standard Library – MiniTest::Mock

Recently we looked at MiniTest, this time around we’re going to dive into MiniTest::Mock, a tiny library that will let you test systems that would otherwise be very difficult to test. We will take a look at what MiniTest::Mock provides, and then how it works.

A MiniTest::Mock Example

If you’re not familiar with Mock objects in general, wikipedia has a nice article on them. Let’s imagine that we want to write a script that deletes any email messages that are more than a week old:

class MailPurge
    def initialize(imap)
      @imap = imap
    end

    def purge(date)
      # IMAP wants dates in the format: 8-Aug-2002
      formatted_date = date.strftime('%d-%b-%Y')

      @imap.authenticate('LOGIN', 'user', 'password')
      @imap.select('INBOX')

      message_ids = @imap.search(["BEFORE #{formatted_date}"])
      @imap.store(message_ids, "+FLAGS", [:Deleted])
    end
  end

We want to make sure that MailPurge only deletes the messages the imap server says are old enough. Testing this will be problematic for a number of reasons. Our script is going to be slow if it has to communicate with the server, and it has the permanent side effect of deleting your email. Luckily we can drop a mock object in to replace the imap server. We need to make a list of all the interactions our code has with the imap server so that we can fake that part of the server. We can see our script will call authenticate, select, search, and store, so our mock should expect each call, and have a reasonable response.

def test_purging_mail
    date = Date.new(2010,1,1)
    formatted_date = '01-Jan-2010'
    ids = [4,5,6]

    mock = MiniTest::Mock.new

    # mock expects:
    #            method      return  arguments
    #-------------------------------------------------------------
    mock.expect(:authenticate,  nil, ['LOGIN', 'user', 'password'])
    mock.expect(:select,        nil, ['INBOX'])
    mock.expect(:search,        ids, [["BEFORE #{formatted_date}"]])
    mock.expect(:store,         nil, [ids, "+FLAGS", [:Deleted]])

    mp = MailPurge.new(mock)
    mp.purge(date)

    assert mock.verify
  end

We call MiniTest::Mock.new to create the mock object. Next we set up the mock’s expectations. Each expectation has a return value and an optional set of arguments it expects to receive. You can download this file and try it out (don’t worry it won’t actually delete your email). The MailPurge calls our fake imap server, and in fact does delete the message ids the server sends back in response to the @imap.search. Finally, we call verify which asserts that MailPurge made all the calls we expected.

How it Works

Lets dive into the source, if you have Qwandry you can open it with qw minitest. Looking at mock.rb you will see that MiniTest::Mock is actually quite short. First let’s look at initialize.

def initialize
  @expected_calls = {}
  @actual_calls = Hash.new {|h,k| h[k] = [] }
end

We can see that Mock will keep track of which calls were expected, and which ones were actually called. There is a neat trick in here with the Hash.new {|h,k| h[k] = [] }. If a block is passed into Hash.new, it will get called any time there is a hash miss. In this case any time you fetch a key that isn’t in the hash yet, an array will be placed in that key’s spot, this comes in handy later.

Next lets look at how expect works:

def expect(name, retval, args=[])
  n, r, a = name, retval, args # for the closure below
  @expected_calls[name] = { :retval => retval, :args => args }
  self.class.__send__(:define_method, name) { |*x|
    raise ArgumentError unless @expected_calls[n][:args].size == x.size
    @actual_calls[n] << { :retval => r, :args => x }
    retval
  }
  self
end

This looks dense, but if you take a moment, it’s straightforward. As we saw in the example above, expect takes the name of the method to expect, a value it should return, and the arguments it should see. Those parameters get recorded into the hash of @expected_calls. Next comes the tricky bit, MiniTest::Mock defines a new method on this instance that verifies the correct number of arguments were passed. The generated method also records that it’s been called in @actual_calls. Since @actual_calls was defined to return an array for a missing key, it can just append to whatever the hash returns. So expect dynamically builds up your mock object.

The final part of Mock makes sure that it did everything you expected:

def verify
  @expected_calls.each_key do |name|
    expected = @expected_calls[name]
    msg = "expected #{name}, #{expected.inspect}"
    raise MockExpectationError, msg unless
      @actual_calls.has_key? name and @actual_calls[name].include?(expected)
  end
  true
end

We can see here that verify will check each of the @expected_calls and make sure that it was actually called. If any of the expected methods aren’t called, it will raise an exception and your test will fail. Now you can build mock objects and make sure that your code is interacting the way you expect it to.

You should be aware though that MiniTest::Mock does not have many of the features that much larger libraries such as mocha do. For instance it does not let you set up expectations on existing objects, and requires you to specify all the arguments which can be cumbersome.

So we have dived into another piece of ruby’s standard library and found some more useful functionality. Hopefully along the way you have lerned some uses for mocking, and a neat trick with ruby’s Hash.

blog comments powered by Disqus
Monkey Small Crow Small