Mocking is one way to achieve isolation in tests. It is the practice of using a fake object in place of a collaborator object. In the simplest case, a mock will accept a method call, do nothing, and return a value (usually nil
in Ruby).
Mocks can also stub methods to return realistic values. They can provide static responses or use arguments to calculate a return value. It’s often as simple as specifying the expected method arguments and a response.
A simple stubbing might look like this:
stubs { mailer.deliver("a message") }.with { "canned response" }
mailer.deliver("a message") # => "canned response"
But stubbing methods isn’t always so straightforward. When a Ruby method takes a block to call later, it’s harder to identify whether, when, and how to call that block. It’s not as easy as the method mock above that can discard or do minimal work with arguments. Blocks often have side effects that may be important and that we need to assert against.
Let’s look at HeyYou, a Postgres LISTEN/NOTIFY client for Ruby. It has a method #listen that takes a block. It listens for notifications and executes the passed block when they trigger:
def collate_notifications
HeyYou.new.listen do |notification|
@notifications << notification
end
end
We can use mocking as a tool to test this method’s behavior in isolation. If we were to test collate_notifications
without faking HeyYou
, it would need a real Postgres connection, which would register a real listener, which would only call our block if our test also triggered a real notification in the database.
That complexity might all be appropriate in an integration test, but would represent an awful lot of distracting hassle to an isolated unit test’s attempt to specify the method’s logical behavior. HeyYou and the Postgres database are collaborators; their behavior isn’t part of the system under test.
An isolated unit test of this method would be deterministic, specific, and performant.
Excluding dependencies such as the database makes our test is more deterministic. Fewer outside elements can cause failures due to inputs beyond our control. We eliminate things like LISTEN/NOTIFY calls and other database clients issuing their own statements.
The test is specific in that it lays out the behavior of the collaborator. We care that it calls the return block with the notification, so we call the block with the notification.
Finally, the test is more performant because it no longer needs to make network calls to the database. Even local networks are slower than Ruby calls. Further, the Ruby call stack is shorter and follows a path optimized by the mocking libraries to be fast.
Our contrived block that we passed to HeyYou#listen
is very simple, only adding the message it receives to some collection. Imagine if that block had a dozen if/else conditions for our test to cover? And what if it took 5 seconds of wall time every time it was called through to the database? A naive integration test could take a minute at runtime whereas an isolated one would probably be under half a second.
An integration test of the method is flaky, declarative, and slower. It would also give us confidence that the interactions between our units behave correctly under realistic conditions.
The exposure to the database opens us up to an integration test failing unexpectedly. Even when run against the same code, the IO from the database exposes us to flakes. The integration test doesn’t control the database and shouldn’t. The database isn’t what we are testing.
A benefit to integration tests is that they are more declarative. We tell the code what to do rather than how to do it. The result is a test that may have fewer lines of code. It is more focused on the interaction between units. It has less setup mocking or faking collaborators.
Including the database in the test also leads to the slower test runs.
The testing pyramid suggests using many unit tests, fewer integration tests, and still fewer end-to-end tests. The latter is variously referred to as “feature”, “acceptance”, “UI”, or “system” tests. Following that, we can search for a compromise between the speed of unit tests and assurance of integration tests.
Mocking #listen
is difficult. The test needs to execute the block to assert against the side effects of it being called. The method usually calls the block in reaction to an external stimulus. The block isn’t executed at the same point it is passed to #listen
, but rather in reaction to a NOTIFY event. Mocked methods do not do anything with arguments or blocks passed to them by default. However, our test can’t exercise the behavior we want to verify unless the block is called. Because of this, it’s not as simple as mocking the method to return some useful value—we need to invoke the block just like HeyYou would.
Here’s how we can do it in Mocktail, Test Double’s mocking gem:
notification = "A useful notification"
hey_you = Mocktail.of_next(HeyYou)
stubs { hey_you.listen do |block|
block.call(notification)
end }
sut = Collator.new
sut.collate_notifications
assert_equal [notification], sut.notifications
Or with rspec-mocks’ more verbose, indirect syntax, the mocking would look like:
notification = "A useful notification"
hey_you = instance_double(HeyYou)
allow(HeyYou).to receive(:listen) do |&block|
block.call(notification)
end
allow(HeyYou).to receive(:new).and_return(hey_you)
Mocking—with or without callbacks—gives us confidence that our units are correct. It also provides speedier test runs to cover the numerous edge cases and code paths we want to test. To give us confidence that the pieces work together correctly when we combine them, we can also write integration tests. One covering the “happy path” and one or two for “sad paths” would give us confidence that the units work together as a system.
Mocking allows us to isolate what we are testing from objects that it happens to interact with. Isolation not only provides deterministic results but also leads to speedier tests. Callbacks require special handling when the methods they are passed to are mocked, but going that extra step reaps benefits.