This article has been republished on Monkey and Crow.
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
.