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 adocument
- 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:
- that it was invoked with the right parameters
- 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.
- Most importantly,
spy
is a function call that creates a new mock object. -
described_class
andsubject
are conventional ways to refer to the subject under test. In this casedescribed_class
isFoo
andsubject
is an instance ofFoo
. - 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.