Simple Ruby Mocking «
»


Code: , , , , , ,
3 comments

I mentioned at the end of my last post on testing that I wrote some code to do mocking for my unit tests in Ruby. Writing a small mock library was very much reinventing the wheel, but I needed to do it to earn a deeper understanding of mocks.

I’m writing some code (“Fetcher”) to talk to a POP3 server, fetch mail, and pass it off to another process. One of the tests deals with what happens when the POP3 server is down or otherwise unreachable.

def test_setup_server_down
  pop3 = Mock.new
  pop3.expect(:new, [MAIL_SERVER, MAIL_POP3_PORT]){ raise Timeout::Error.new("execution expired") }
  f = Fetcher.new(pop3)
  # further assertions that f acts correctly
end

This says that the Mock object expects a call to the new method with the given arguments, and when the call happens it runs the block. The block could return anything, but in this case it raises the same error as Net::Pop3 does when it can’t contact the server. After that the test can go on to make whatever assertions it needs to verify that the exception was handled properly.

The Mock object has a list of calls it expects to see and keeps a list of how it’s been called (yes, this could just be one list with an index but I thought it was mentally simpler this way). The test sets up what calls it should expect to see with what arguments (or blank for any) and block to run (or blank for none). When a method is called on the mock object, method_missing logs the call and executes the given block (raising a fuss if the call didn’t match what it expected).

class Mock
  attr_reader :calls, :called
 
  # the stub arg makes it just record all calls
  def initialize
    @calls = []
    @called = []
  end
 
  # Pass nil for args to ignore the actual args in the call.
  # Proc is optional; default is empty proc returning nil.
  def expect(method, *args, &proc)
    @calls << {:method => method, :args => args.first, :proc => (proc or Proc.new{})}
  end
 
  def method_missing(method, *args)
    @called << {:method => method, :args => args}
 
    expect = @calls.shift
    raise "Unexpected mock call #{method.to_s}(#{args.join(', ')})" if expect.nil?
    raise "Wrong mock call #{method.to_s}(#{args.join(', ')}); expected #{expect[:method]}(#{expect[:args].join(', ')})" if method != expect[:method] or (expect[:args] != nil and args != expect[:args])
    expect[:proc].call(*args)
  end
end

It’s a straightforward little object, and I also added some code to raise a fuss if expected calls weren’t made. This does have the downside that any tests defining their own teardown need to call super.

class Mock
  def fail_if_not_empty
    # Empty the call stack so that this obj doesn't throw errors for
    # every later test between now and this object getting gc'd
    calls, @calls = @calls, []
    raise "Mock calls uncalled: n" + calls.collect { |call| "#{call[:method]}(#{call[:args]} { #{call[:proc] })" }.join(" ") unless calls.empty?
  end
end
 
class Test::Unit::TestCase
  def teardown
    finish_mocks
  end
 
  def finish_mocks
    ObjectSpace.each_object(Mock) do |m|
      m.fail_if_not_empty
    end
  end
end

This has been a handy piece of code to test the code I’ve written in the last two weeks, but it’s not good enough. I have to use a technique called dependency injection to test Fetcher.new, where the outside code passes it a POP3 object instead of its initialize just using Net::POP3. Useful for testing, but my code is badly repetitive when all the instantiation calls have to do this exact same setup. (As an aside, Jacob Proffitt recently started an interesting conversation on dependency injection, took criticism, and responded. Good reading.)

I was pondering how to extend the Mock object to let me mock class methods (eg a call to Net::POP3.new) when I realized I’d gone as far as I should down the do-it-yourself road. I’d heard of Mocha and it took me all of ten minutes to think to look at its cheat sheet where that’s the first example.

After spending two hours or so writing and tweaking this code, the best thing for it is to be thrown away. I’ve learned about mocking by doing it and I’m better-prepared to understand someone else’s larger and better library.


Comments

  1. Pingback: Mocha - Push cx

Leave a Reply

Your email address will not be published.