Skip to main content

Testing in Go: Test Doubles by Example

·8 mins

One of the biggest misconceptions is that doubles are a specific implementation of mocks or other testing constructs that we use in testing.

Dummies, mocks, stubs, fakes, and spies ARE test doubles. Test double is the category of these test constructs. Over the years, there has been some confusion around this concept.

It is my observation that this confusion arises from the naming of testing constructs that the popular testing frameworks use. Also, the words mock and doubles have been used interchangeably over the years, and their definitions got skewed.

In any case, without further ado, let’s dive in and explore each of these categories of test doubles.

Girl with geometric objects

Dummies #

The simplest type of test double is a test dummy. In a nutshell, it means that the instance is just a placeholder for another one, but its functions do not return anything.

Let’s imagine the following scenario. We have a Phonebook type that has a slice of Person as an attribute. Each Person has a FirstName, LastName, and Phone attributes. Also, the Phonebook has a Find method through which we can find a person’s phone number using their first and last names.

The Find method uses a Searcher object whose Search function it uses to find the entry in the slice of people. Searcher’s implementation is not essential at this moment.

package main

import (
	"errors"
)

var (
	ErrMissingArgs   = errors.New("FirstName and LastName are mandatory arguments")
	ErrNoPersonFound = errors.New("No person found")
)

type Searcher interface {
	Search(people []*Person, firstName string, lastName string) *Person
}

type Person struct {
	FirstName string
	LastName  string
	Phone     string
}

type Phonebook struct {
	People []*Person
}

func (p *Phonebook) Find(searcher Searcher, firstName, lastName string) (string, error) {
	if firstName == "" || lastName == "" {
		return "", ErrMissingArgs
	}

	person := searcher.Search(p.People, firstName, lastName)

	if person == nil {
		return "", ErrNoPersonFound
	}

	return person.Phone, nil
}

Imagine we want to test the Find method of the Phonebook. How would you approach it? Here’s one way:

type DummySearcher struct{}

func (ds DummySearcher) Search(people []*Person, firstName, lastName string) *Person {
	return &Person{}
}

func TestFindReturnsError(t *testing.T) {
	phonebook := &Phonebook{}

	want := ErrMissingArgs
	_, got := phonebook.Find(DummySearcher{}, "", "")

	if got != want {
		t.Errorf("Want '%s', got '%s'", want, got)
	}
}

To test that the Find method returns an error when one of the arguments is blank, we do not care about the implementation of the Searcher argument. In such cases, where the functionality of the injected dependency is irrelevant, all we need is an instance that we can just pass in, and the compiler won’t complain.

Such instances are called dummies. They are test doubles that have no functionality and that we don’t want people to use.

Stubs #

Using the same example from above, how would we test that the Find method when supplied the firstName and lastName arguments will work as expected?

type StubSearcher struct {
	phone string
}

func (ss StubSearcher) Search(people []*Person, firstName, lastName string) *Person {
	return &Person{
		FirstName: firstName,
		LastName:  lastName,
		Phone:     ss.phone,
	}
}

func TestFindReturnsPerson(t *testing.T) {
	fakePhone := "+31 65 222 333"
	phonebook := &Phonebook{}

	phone, _ := phonebook.Find(StubSearcher{fakePhone}, "Jane", "Doe")

	if phone != fakePhone {
		t.Errorf("Want '%s', got '%s'", fakePhone, phone)
	}
}

When we want to make the Searcher implementation return an actual value that we can assert against, we need a stub. Instead of understanding what it would take to set up and/or implement a proper Searcher, you just create a stub implementation that returns only one value. This is the idea behind stubs.

Spies #

Again, using the Phonebook and Searcher example from above, let’s imagine we would like to write a test where we want to be sure we invoke the Searcher.Search function. How can we do that?

type SpySearcher struct {
	phone           string
	searchWasCalled bool
}

func (ss *SpySearcher) Search(people []*Person, firstName, lastName string) *Person {
	ss.searchWasCalled = true
	return &Person{
		FirstName: firstName,
		LastName:  lastName,
		Phone:     ss.phone,
	}
}

func TestFindCallsSearchAndReturnsPerson(t *testing.T) {
	fakePhone := "+31 65 222 333"
	phonebook := &Phonebook{}
	spy := &SpySearcher{phone: fakePhone}

	phone, _ := phonebook.Find(spy, "Jane", "Doe")

	if !spy.searchWasCalled {
		t.Errorf("Expected to call 'Search' in 'Find', but it wasn't.")
	}

	if phone != fakePhone {
		t.Errorf("Want '%s', got '%s'", fakePhone, phone)
	}
}

You can think of spies as an upgrade of stubs. While they return a predefined value, just like stubs, spies also remember whether we called a specific method. Often, spies also keep track of how many times we call a particular function.

That’s what spy is - a stub that keeps track of invocations of its methods.

Mocks #

In the beginning, people started using mock for similar (but not the same) things, and the word got left hanging in the air without a proper definition. Some think of stubs as mocks; others do not even think of mocks as types of instances.

It’s generally accepted to use “mocking” when thinking about creating objects that simulate the behavior of real objects or units.

But mocks are a thing of their own. They have the same characteristics as the stubs & spies, with a bit more.

Also, in Go, they are a bit tricky to implement, especially in a generic way. Still, for this example, we will do a home-made implementation:

type MockSearcher struct {
	phone         string
	methodsToCall map[string]bool
}

func (ms *MockSearcher) Search(people []*Person, firstName, lastName string) *Person {
	ms.methodsToCall["Search"] = true
	return &Person{
		FirstName: firstName,
		LastName:  lastName,
		Phone:     ms.phone,
	}
}

func (ms *MockSearcher) ExpectToCall(methodName string) {
	if ms.methodsToCall == nil {
		ms.methodsToCall = make(map[string]bool)
	}
	ms.methodsToCall[methodName] = false
}

func (ms *MockSearcher) Verify(t *testing.T) {
	for methodName, called := range ms.methodsToCall {
		if !called {
			t.Errorf("Expected to call '%s', but it wasn't.", methodName)
		}
	}
}

func TestFindCallsSearchAndReturnsPersonUsingMock(t *testing.T) {
	fakePhone := "+31 65 222 333"
	phonebook := &Phonebook{}
	mock := &MockSearcher{phone: fakePhone}
	mock.ExpectToCall("Search")

	phone, _ := phonebook.Find(mock, "Jane", "Doe")

	if phone != fakePhone {
		t.Errorf("Want '%s', got '%s'", fakePhone, phone)
	}

	mock.Verify(t)
}

This approach is more involved, but there’s no magic to it. The MockSearcher implementation has a methodsToCall map, which will store all of the methods that we expect to call on an instance of this type.

The ExpectToCall method will take a method name as an argument. It will store the method to the methodsToCall map (as the key) and a false as the value. By setting it to false, we set a mark on that method that we expect to call it (yet we still haven’t called it).

In MockSearcher’s Search method, we mark the Search method as called. We do this by setting the true value for the "Search" key in the methodsToCall map. In essence, the key we’ve set to false in the ExpectToCall method we set to true here.

Lastly, the Verify method will go over all of the methods that we marked as to-be-called on the mock. If it finds one still set to false, it will mark the test as failed.

Mocks work by setting certain expectations on them. We implement some stub-like functionality while keeping track of the methods that have been called. Finally, we ask the mock to verify if our code met all of its expectations.

It’s worth stating that this is a home-made solution, and as such, it has some caveats. If you would like to use mocks in your tests, there are some excellent Golang libraries out there like golang/mock or stretchr/testify.

Fakes #

These test doubles, unlike stubs, spies, and mocks, truly have an implementation. In our example, this would mean that a fake would have an actual implementation of the Search method.

Let’s see a Searcher fake in action:

type FakeSearcher struct{}

func (fs FakeSearcher) Search(people []*Person, firstName string, lastName string) *Person {
	if len(people) == 0 {
		return nil
	}

	return people[0]
}

func TestFindCallsSearchAndReturnsEmptyStringForNoPerson(t *testing.T) {
	phonebook := &Phonebook{}
	fake := &FakeSearcher{}

	phone, _ := phonebook.Find(fake, "Jane", "Doe")

	if phone != "" {
		t.Errorf("Wanted '', got '%s'", phone)
	}
}

What you see is a FakeSearcher type, who’s Search method has an implementation that (kinda) makes sense. If the slice of *Person is empty, it will return nil. Otherwise, it will return the first item in the slice.

While this Search method is not production-ready, because it doesn’t make much sense, it still has the behavior of a fake Searcher. If we didn’t have it’s implementation accessible (looking at it as a black box), one could think it’s the real deal instead of a fake.

That’s what fakes are - implementations that look like the real thing. But they only do the trick in the scope of the test.

Another such familiar example in the community is an in-memory database driver used as fake to a real database driver.

When To Use What #

Now that we went through all of the test doubles and their implementations, the last question that we need to answer is: what test double should we use in which circumstances?

As with many other things in software development, the answer is: it depends.

You must keep in mind is what are you really testing. That is also known as the unit under test. If the unit under test requires a double of any sort, you should provide the simplest test double that will make your test do its work and that it will suffice to make the test pass.

So, refrain from using bloated mocks if a stub does the trick. The rule of thumb is: use the simplest test double that you can. And after the test passes, see if you can refactor and simplify further.

And lastly, make sure not to downright mock out everything – sometimes invoking the actual implementation (e.g., like in an integration test) can be a great idea.

Additional reading: