Writing Mockable Code in Go
Introduction
Experienced engineers will agree how important writing tests is, and most of the time even prevent a Pull Request from going through when the appropriate tests are not added/modified.
With tests being so important, we need to understand how to write testable/mockable code. If you are new to mocking, you can read the basics first, this is a good reference blog to start with.
Let’s identify the testing best practices in the next section, with the help of a microservice that exposes a single endpoint /coffee
and returns a coffee type.
Tasting (read: Testing) Coffee
Code Walkthrough
We have a main.go
file from where the code flow begins:
1 | package main |
The GetCoffee()
function in coffee_controller.go
interacts with the appropriate service:
1 | package controllers |
The HandleCoffee()
function in coffee_service.go
handles the main logic:
1 | package services |
Now run the application using go run main.go
and call the API using curl localhost:8080/coffee
or curl localhost:8080/coffee -v
:
1 | ~/Projects/GoProjects/golang-unit-test » curl localhost:8080/coffee |
Basic Test
Let’s write a basic test to verify the error code and the result:
1 | package controllers |
Let’s run the test with coverage:
1 | ~/Projects/GoProjects/golang-unit-test/code/controllers(master*) » go test . -v -cover |
We see that as expected, our test does pass. But only 75% of the lines were covered. This is because our test doesn’t cover the error scenario, and the following lines from coffee_service.go
are not evaluated:
1 | if err != nil { |
We want to develop a test case that tests this scenario as well. In order to do that we need to have complete and full control over what this HandleCoffee()
function returns. Because Go is a compiled language, you cannot mock what this function actually does once this code is compiled.
So, the first thing that we need to keep in mind when writing mockable code is that if we put a package function, we are not going to be able to mock this function. So we need to make sure that we always use interfaces where we want to mock.
Before adding an interface though, let’s add a struct in the next section.
Adding struct
We add a struct called coffeeService
and bind the function HandleCoffee()
to this service, making it a method.
1 | package services |
Note that we have added a public variable CoffeeService
so that other packages (like controllers) can use it to access the service.
Adding interface
Next we add the interface coffeeService
and rename our struct to coffeeServiceImpl
, indicating that this struct now implements the interface.
1 | package services |
Modifying the test
Now we modify our test as shown below:
1 | package controllers |
We are using the same interface we created in the service to create a mock. We provide a custom implementation of this mock in the test itself, and then override this mock implementation in the test case so it gets used during the test run (instead of the actual service implementation).
This can be verified when we see the line mocking complex things...
being printed instead of doing complex things here...
while running the test case. On commenting the line services.CoffeeService = coffeeServiceMock{}
in the test, we will again see doing complex things here...
being printed as the mock implementation will not be used anymore.
Making code coverage 100%
If we run the test, our code coverage is still 75% due to the same reasons discussed earlier. Although, we now have extensible code where we can provide a custom implementation of the HandleCoffee()
method in order to test the error scenario as well.
1 | package controllers |
We added an attribute handleCoffeeFn
inside the custom implementation of the CoffeeService interface, i.e. coffeeServiceMock
. This attribute helps us plugin different implementations of HandleCoffee() method as per test case requirements.
Now if we run the test with coverage, we get a 100% result:
1 | ~/Projects/GoProjects/golang-unit-test/code/controllers(master*) » go test . -v -cover |
Learnings
In order to write testable code in Go, we need to keep the following points in mind:
- Implement functions not as functions, but as methods, i.e. use structs
- Define an interface that defines every method we need in the struct
- Inside every test case, specify the behaviour you expect from the mocked object. This makes it easy for you to have control over what the function call actually returns.
Mocking Frameworks
Like every other programming language, Go also has various libraries and packages to help generate mocks and write tests.
But if you are new to testing, let’s first understand why there is a need for mocking frameworks in the first place.
Need for Mocking Frameworks
Say that you are testing your code that is still in development. In order to achieve the right results, you need to test its interactions with system resources, outside applications, and other dependencies. Unfortunately, you learn early on that that is not possible. Utilizing a mocking framework allows for realistic emulations of the required interactions.
Mocked objects take the place of any large/complex/external objects your code needs access to in order to run.
They are beneficial for a few reasons:
- Your tests are meant to run fast and easily. If your code depends on, say, a database connection then you would need to have a fully configured and populated database running in order to run your tests. This can get annoying, so you create a replacement - a “mock” - of the database connection object that just simulates the database.
- You can control exactly what output comes out of the Mock objects and can therefore use them as controllable data sources for your tests.
- You can create the mock before you create the real object in order to refine its interface. This is useful in Test-driven Development.
Mocking Frameworks in Go
In a typical business application with a lot of code, writing mocks can easily increase the development time. Fortunately, the following tools come in very handy in Go for writing unit tests:
mockery is an awesome tool to easily generate mocks for golang interfaces. It uses the testify/mock package internally.