Here’s how to test dependencies with mocks

d_ir

Daniel Irvine 🏳️‍🌈

Posted on February 29, 2020

Here’s how to test dependencies with mocks

In this post I’ll present a straightforward process for testing dependencies. No matter which language, framework or unit testing tool you’re using, you can rely on this process to fully test your dependency use.

I’m using Ruby for my code samples but they apply in just about every modern programming language.

Let’s look at an example of a class with a dependency.

class FooRepository
  def save(id, docoment)
    # ...
  end
end

class Foo
  def initialize(repository)
    @repository = repository
  end

  def do_something(document)
    @repository.save('Foo', document)
  end
end

In this example repository is the dependency. We’re going to write tests for the do_something function.

A dependency of a code unit (an object, or a function) is another code unit that is executed as part of the containing code unit’s execution.

In an object-oriented environment we often see dependencies passed into the class constructor, as in the example above. But functions can have dependencies too.

In this case, Foo does not create an instance of repository. It does not control the lifetime of the dependency. In fact, it knows nothing more about repository than these two facts:

  • it has a method named save that takes two arguments: a string and a document
  • it returns either nil or something else (we don’t care what else)

And it’s these two facts that lead our testing.

Every time we test a dependency, we need at least these two tests:

  1. that it was invoked with the right parameters
  2. that the return value was used correctly

Test 1: the dependency is invoked with the right parameters

This example uses some special RSpec syntax that you may not be familiar with, which I’ll explain after:

require 'foo'

RSpec.describe Foo do

  let(:repository) { spy }

  subject do
    described_class.new(repository)
  end

  it 'calls repository#save with the name and document' do
    subject.do_something(:document)

    expect(repository).to have_received(:save)
      .with('Foo', :document)
  end
end

If you’re not familiar with RSpec, here are three points that should help explain this example.

  1. Most importantly, spy is a function call that creates a new mock object.
  2. described_class and subject are conventional ways to refer to the subject under test. In this case described_class is Foo and subject is an instance of Foo.
  3. More subtly, :document is a symbol that allows us to give a name to a value but we don’t care about the value itself. More on this later.

Essentially what we do is place a spy object—a type of test double which records its invocations—where the dependency should be. We then invoke the method we’re testing, and then check the spy’s records to ensure that it was indeed invoked.

Test 2: the dependency’s return value is used correctly

If you’re unfamiliar with Ruby, you may look at the code above and think that we simply throw away the return value. But this isn’t true. In Ruby, the returned value of a function is the value of the last expression, which in this case is the returned value of the call to save. There implicity behavior here that we’re relying on.

To test, we stub a return value using the allow function.

it 'returns the result of calling repository#save' do
  allow(repository).to receive(:save).and_return(:saved_document)

  expect(subject.do_something(:document)).to be :saved_document
end

Notice again that it’s not the value of the return value that’s important, but the identity. We want to check that it’s the same value we passed in. So we can use a symbol, :saved_document to do that without having to define an extra variable.

Modification 1: Protecting against dependency signature changes in dynamic languages

Imagine now that we rename the save method of repository to save_document, but we forget to update Foo: it still calls save:

class FooRepository
  # renamed from save
  def save_document
    ...
  end
end

class Foo
  def do_something(document)
    @repository.save('Foo', document) # ERROR: still calls save!
  end
end

Because Ruby doesn’t do any static analysis of our functions, our tests will still pass even though save no longer exists. This code will break at runtime.

Wouldn’t it be great if we could make our tests fail when the dependency method signature changes?

Failure/Error:
  allow(repository).to receive(:save).and_return(:saved_document)

  the FooRepository class does not implement the instance method: save

We can do that by replacing the call to spy with instance_spy, which produces a verifying double:

let(:repository) { instance_spy(FooRepository) }

This isn’t unique to RSpec: all good mocking frameworks will support this functionality.

A verifying double will break your test if you try to stub a method that doesn’t exist on the original class.

Modification 2: Add a test for every new branch

Now imagine that do_something looks like this:

def do_something(document)
  result = @repository.save('Foo', document)
  :ok if result
end

This code changes the return value of do_something depending on the returned value of save. If save returns anything, then the result is :ok, otherwise the return value is nil. Because of that, we now need two tests for the return value; one test for each branch.

it 'returns :ok when repository#save returns something' do
  allow(repository).to receive(:save).and_return(:saved_document)

  expect(subject.do_something(:document)).to be :ok
end

it 'returns nil when repository#save returns nil' do
  allow(repository).to receive(:save).and_return(nil)

  expect(subject.do_something(:document)).to be_nil
end

Systemizing testing

It’s important to systemize your testing. In this post I’ve shown you one way you can do that: every time you want to test a dependency, you can follow this process to ensure your code is correctly covered.

💖 💪 🙅 🚩
d_ir
Daniel Irvine 🏳️‍🌈

Posted on February 29, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related