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
.
More articles in this series
- Getting to Know the Ruby Standard Library – Delegator
- Getting to Know the Ruby Standard Library – WeakRef
- Getting to Know the Ruby Standard Library – Timeout
- Getting to Know the Ruby Standard Library – Pathname
- Getting to Know the Ruby Standard Library – Abbrev
- Getting to Know the Ruby Standard Library – TSort
- Getting to Know the Ruby Standard Library – MiniTest::Mock
- Getting to Know the Ruby Standard Library – Shellwords
- Getting to Know the Ruby Standard Library – MiniTest