Rent-a-founder

Mocking APIs in Go - An Alternative Approach

It’s fairly common that one has to write unit tests for applications that access external systems. We don’t want to forgo the advantages of unit tests for these applications. So we need to find a way to run them self-contained, without depending on external systems.

Imagine we have a function which saves a user’s password in the database:

func SavePassword(userID, password string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}

	err = psql.UpdatePasswordHash(userID, hash)

	return err
}

And we have one which checks if a user’s password is correct:

func IsPasswordValid(userID, password string) (bool, error) {
	hash, err := psql.GetPasswordHash(userID)
	if err != nil {
		return false, err
	}

	err = bcrypt.CompareHashAndPassword(hash, []byte(password))
	if err != nil {
		return false, err
	}

	return true, nil
}

As one would expect, the UpdatePasswordHash() and GetPasswordHash() functions send queries to the database:

func UpdatePasswordHash(userID string, hash []byte) error {
	_, err := db.Exec("UPDATE users SET password_hash = ? WHERE id = ?", hash, userID)
	return err
}

func GetPasswordHash(userID string) ([]byte, error) {
	var hash []byte
	err := db.QueryRow("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&hash)
	return hash, err
}

If we needed to write a unit test to validate whether a password that was saved can be correctly validated, that unit test would attempt to write to and read from the database, which is not what we want. It would require setting up a database for each test case. This is not optimal, to say the least.

Using a Mocking Framework

A common piece of advice found across the web is to solve the problem by using a mocking framework such as mockery or testify. Or roll your own using monkey patching.

There are lots of tutorials for each of these techniques out there so I’m not going to repeat them here. They all have something in common: They require you to write your production code in a specific way to accommodate your unit tests. Mocking frameworks specifically require you to define an interface for each function to be mocked. They also add auto-generated code to your codebase. Monkey patching often requires you to define your functions as global variables. Some suggest injecting database functions as parameters to your functions, which could then be different in your unit tests:

func SavePassword(userID, password string, updateHash func(string, []byte) error) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return err
	}

	err = updateHash(userID, hash)

	return err
}

In my opinion, doing this hurts readability. It makes your code more difficult to comprehend. Additionally, in almost every scenario, when previewing or following these functions in your IDE, it will point to a variable instead of an actual function. If you’re using interfaces, as is common with many mocking frameworks, your IDE will redirect you to the interface instead of the actual implementation. You can never be sure what actual code you’re dealing with.

Using Build Tags

An alternative approach is to separate production and test code using build tags. In Golang, by placing a build tag at the top of a file you can tell the compiler to include or exclude that file from the build based on tags specified during compilation. To replace some of your production code with mock code, we need to do three things. (1) In your test files (typically ending in _test.go), we add a build tag “mock” to the top of the file:

//go:build mock

(Note that the old // +build mock syntax is deprecated since Go 1.18.)

(2) Now we run our unit tests with the “mock” build tag:

go test -tags=mock

This will lead to a compilation error because we now have two implementations for the same function. So (3) we need to exclude the production code from the build:

//go:build !mock

While we do have to run our unit tests with the “mock” build tag, this approach has the advantage that we do not need to specify any tags for our production builds.

One could even go further and use different tags to separate different types of tests, for example small unit tests for individual functions and larger integration tests that test the entire program.

Mocking the Database

For the purpose of unit testing, the functionality behind many external systems tends to be fairly simple. REST APIs as well as local databases often just implement CRUD interfaces. In many cases, we can replicate their functionality with a simple slice or map. For our password example above, we can use a simple map to store the password hashes:

var (
	users map[string][]byte
	lock  sync.Mutex
)

func UpdatePasswordHash(userID string, hash []byte) error {
	lock.Lock()
	defer lock.Unlock()
	users[userID] = hash
	return nil
}

func GetPasswordHash(userID string) ([]byte, error) {
	lock.Lock()
	defer lock.Unlock()
	hash, ok := users[userID]
	if !ok {
		return nil, fmt.Errorf("user %s not found", userID)
	}
	return hash, nil
}

Note that we add a mutex to make the map thread-safe, in case one of our tests accesses the database from multiple goroutines.

We also need a function to initialize the map before each test case. And we may need a function to check the contents of the map after each test case.

func MockReset(data map[string][]byte) {
	lock.Lock()
	defer lock.Unlock()
	if data == nil {
		users = make(map[string][]byte)
	} else {
		users = data
	}
}

func MockState() map[string][]byte {
	lock.Lock()
	defer lock.Unlock()
	return users
}

Because these are also behind the “mock” build tag, they will not be included in our production builds.

Now we can write our unit test:

func TestPassword(t *testing.T) {
	MockReset(map[string][]byte{
		"user1": nil,
	})

	const password = "somerandompassword"

	SavePassword("user1", password)

	valid, err := IsPasswordValid("user1", password)
	if err != nil {
		t.Error(err)
	}
	if !valid {
		t.Errorf(`password "%s" is not valid`, password)
	}
}

(Using this technique, we can now also turn this unit test into a fuzzing function easily.)

With a framework like mockery or testify, we would instead have to define return values for each combination of function arguments, which can get quite tedious. Implementing an alternative database interface, on the other hand, results in more concise test cases, at least for examples such as this one.

However, using this approach, we run the risk of introducing bugs into our mock implementation, especially when the interface to be mocked is complex. Experience shows, however, that given enough test cases, these bugs are usually caught quickly.

Embedding Test Case Files

We can put our start states and expected end states in a separate file by using the //go:embed directive. Let’s say our application calculates a company’s employees’ bonuses for a specific year according to some algorithm. We can store the start state and the expected end state in a JSON file and embed it into our test case:

//go:embed testcases/0001.json
var testCase1 string

func TestBonuses(t *testing.T) {
	// Initialize the database from the JSON file.
	var tc struct {
		Input    []*Employee
		Expected []*Employee
	}
	if err := json.Unmarshal([]byte(testCase1), &tc); err != nil {
		t.Fatal(err)
	}
	MockReset(tc.Input)

	// Calculate bonuses.
	if err := CalculateBonuses(); err != nil {
		t.Fatal(err)
	}

	// Check the result.
	assert.ElementsMatch(t, MockState(), tc.Expected, "bonuses are not calculated correctly")
}

(This uses a handy assertion function from testify.)

Our JSON file may look like this:

{
  "Input": [
    {
      "Name": "Alice",
      "Salary": 100000,
      "Bonus": 0,
      "TrainingHours": 20
    },
    {
      "Name": "Bob",
      "Salary": 100000,
      "Bonus": 0,
      "TrainingHours": 40
    }
  ],
  "Expected": [
    {
      "Name": "Alice",
      "Salary": 100000,
      "Bonus": 12000,
      "TrainingHours": 20
    },
    {
      "Name": "Bob",
      "Salary": 100000,
      "Bonus": 14000,
      "TrainingHours": 40
    }
  ]
}

Using separate files for test cases becomes more useful when we have many test cases and want to keep them organized and be able to add new ones without having to modify the test code. Here we may want to embed all test files using embed.FS:

//go:embed testcases/*.json
var testCases embed.FS

func TestBonuses(t *testing.T) {
	files, err := testCases.ReadDir("testcases")
	if err != nil {
		t.Fatal(err)
	}
	for _, file := range files {
		data, err := testCases.ReadFile(path.Join("testcases", file.Name()))
		if err != nil {
			t.Fatal(err)
		}
		var tc TestCase
		if err := json.Unmarshal(data, &tc); err != nil {
			t.Fatal(err)
		}

		t.Run(file.Name(), func(t *testing.T) {
			// Run the test case here.
		})
	}
}

Of course this could easily be done by accessing the local file system instead of using embed.FS, but then we would have to worry about getting the paths right, depending on where the go test command is executed. And unless the JSON files are huge, we don’t really need to worry about the resulting executable file size.

Embedding test case files is not strictly related to mocking APIs. It can be used in other testing contexts as well. But it lends itself nicely to the approach outlined above.

Conclusion

Using Golang build tags, alternative test implementations, and embedding test files may not be a new idea. But it presents a viable alternative to using mocking frameworks or monkey patching:

  • Your production code remains clean and does not need to accommodate the requirements of your tests (except for the occasional //go:build tag at the top of the file).
  • No external application (like mockery) is needed to generate mock implementations.
  • No auto-generated code needs to be checked into version control.
  • No need to mock every combination of function arguments.
  • Embedding allows for a clean organization of test case files as part of the repo.

There are drawbacks, of course:

  • Mocking sophisticated APIs with alternative implementations may prove difficult. For such cases, a more traditional approach may be a better choice.
  • Running tests requires the addition of a build tag (go test -tags=mock).
  • Coding and debugging the alternative implementations may require you to temporarily add the build tag to your IDE. Not doing this will result in compilation errors, auto-completion not working, and other unwanted side effects.
  • Mocked code parts need to be in separate files for the build tags to work.
  • We’re not testing the actual external interface. A lot of the bugs resulting from improper handling of an external API may not be caught. This is an issue with all mocking techniques, however.
  • This is a roll-your-own solution. The developers on your team may already have experience with popular mocking frameworks and may be more productive with them.

We tried this approach in one of the projects I worked on and it was very well received by the team (who had worked with both mockery and testify before). It is certainly something I will make use of in other projects, possibly refining the approach given more experience.