wasabigeek

Practical Metaprogramming in Ruby: minitest/mock

January 22, 2022

minitest/mock (repo) is a tiny mocking library (< 250 lines of code, including comments) that’s also a great showcase for how metaprogramming can be used in Ruby - let’s take a look! If you’re new-ish to Ruby, I hope this gives you concrete examples of what metaprogramming is possible, and how to do it.

What is metaprogramming?

In the context of this article, I’m referring to programs and code that are able to do introspection (inspect themselves e.g. type or properties) and reflection (modify their behaviour).

First example: Set expectations with method_missing

The main feature of minitest/mock is the Minitest::Mock class, which can be used to test that a method is called, for example:

require 'minitest/mock'

class ClassUnderTest
  def initialize(foo)
    @foo = foo
  end

  def call
    @foo.bar
  end
end

# create an instance of Mock and set an expectation
# that the method `bar` will be called on it
mock_foo = Minitest::Mock.new
mock_foo.expect(:bar, 'example_return_value')

# inject the mock into the Object-Under-Test
# which essentially calls `mock_foo.bar`
ClassUnderTest.new(mock_foo).call # => returns 'example_return_value'

# check that `bar` was called
mock_foo.verify

Notice that there’s nowhere in the library where the method bar is explicitly defined, yet running the above doesn’t raise a NoMethodError. We were able “define” (well, not exactly, but we’ll get to that) a new method after the mock object was already instantiated, via the expect call.

The truth is expect (source code) is straightforward Ruby; it saves the expected call to an Array instance variable - no new method gets defined. The real magic happens in method_missing :

# lib/minitest/mock.rb#L118
def method_missing sym, *args, &block
  # ...
  expected_call = @expected_calls[sym][index]
  # ...
  expected_args, retval, val_block = expected_call.values_at(:args, :retval, :block)
  # ...
  @actual_calls[sym] << {
    :retval => retval,
    :args => zipped_args.map! { |mod, a| mod === a ? mod : a },
  }
end

If a method is called that doesn’t exist, Ruby will invoke method_missing with a few arguments:

  • the method’s name in symbol form (sym in the above example)
  • the arguments (*args ) and block (&block) passed to the non-existent method

Minitest::Mock uses this to catch any undefined method that is called, checking it against the saved expectations. This makes it seem like the object has that method defined!

Remove inherited methods via undef_method

While Minitest::Mock doesn’t explicitly define many methods, it does inherit methods from Ruby’s Object class, such as #nil?.

What if we want to check that a mock receives a method call, like #nil?? Without any intervention, method_missing would not be invoked, since those methods exist! Something like this would happen:

mock.expects(:nil?, true)
mock.nil? # => false (this is the default for Object!)
mock.verify # raises MockExpectationError, because the original `#nil?` method is called instead of `method_missing`!

So what the library does here is to undefine them when the class is interpreted. instance_methods is used to introspect the class for any instance methods, which are removed via undef_method:

# lib/minitest/mock.rb#L26
instance_methods.each do |m|
  undef_method m unless overridden_methods.include?(m.to_s) || m =~ /^__/
end

Calling #nil? now would properly invoke method_missing.

Redefining Methods with define_method

In the code above, we saw that not all instance_methods were removed, there are some overridden_methods (e.g. inspect) where the library opts to keep the default implementation unless the user has explicitly set an expectation. This is done by re-defining them via define_method:

# lib/minitest/mock.rb#L30
overridden_methods.map(&:to_sym).each do |method_id|
  define_method method_id do |*args, &b|	
    if @expected_calls.key? method_id then
  	  method_missing(method_id, *args, &b)
    else
  	  super(*args, &b)
    end
  end
end

The goal seems to be so that a Minitest::Mock instance still responds like a Ruby object e.g. when printing it in the terminal 😄.

Stubbing by redefining methods on “Metaclasses”

minitest/mock also has a stubbing feature, which allows you to redefine the return value of an object within a block:

Time.stub(:now, "example") do
  Time.now # => "example"
end
Time.now # => actual time...

Let’s look at the implementation - the library first opens the Object class, and defines a stub method there:

class Object
  # lib/minitest/mock.rb#L214
  def stub name, val_or_callable, *block_args
    # ...
  end
end

Since Object is the ancestor of all almost everything in Ruby (including Classes and Modules), we are able to call stub on basically anything. The actual implementation of stub is the interesting bit, introducing us to Ruby’s “metaclasses” (also known as “singleton classes” or “eigenclasses”):

class Object
  # lib/minitest/mock.rb#L214
  def stub name, val_or_callable, *block_args
  	new_name = "__minitest_stub__#{name}"
	
  	metaclass = class << self; self; end
  	# ...
    metaclass.send :alias_method, new_name, name

  	metaclass.send :define_method, name do |*args, &blk|
	  # return or execute val_or_callable
	end

    # ...
    yield self
  ensure
    # cleanup...
  end
end

There’s some further reading on metaclasses and that mysterious class << self syntax in the footnotes, but one way to understand the above is to replace the following lines:

metaclass = class << self; self; end
metaclass.send :define_method, name do |*args, &blk|
  # ...
end

…with:

def self.name(*args, &blk)
  # ...
end

In other words, stub is defining (or overwriting) the method on the object - the type of method (class/instance) being neatly handled by accessing the metaclass. (Note: self.define_method would not work for class methods, since self returns the class, it would define instance methods.)

The original logic is kept safe by renaming it via alias_method, and in the ensure block, there is code to reverse this change. These ensure stubbing only takes effect in the stub block.

Footnotes

Note that metaprogramming, while powerful, comes with a performance and readability cost. Use with care!

For further reading on various topics touched in this article, check these out:

Related Posts

Overspecifying Tests - the Employee Report Kata

Reading a Ruby gem with VSCode

Mocking in RSpec VS Minitest: A Cheatsheet

Comments


Nick

By Nick, a Business grad turned Software Engineer, living in sunny 🇸🇬. I write mostly about Ruby and Rails, not wasabi (sorry!).