Mocking in Go tests

2024-02-04

Intro

This post will cover how mocking can be used within tests, specifically unit tests to enhance code coverage and the reliability of tests by removing the reliance on external dependencies.

This post relies on knowledge regarding unit testing and dependency injection, if these concepts are already understood skip to the test scenario otherwise continue reading below to read an explanation.

Unit Testing

Unit testing is about testing the smallest individual components within your system, commonly this is a function. The purpose of this is to remove assumptions about the behavior and prove that the piece of functionality is valid. Unit testing is a decisive concept and definitions may differ but this my definition.

As an example imagine you are refactoring an existing code base that is thoroughly unit tested, this gives you the confidence to make changes to the implementation and by running the test suite you will know if you have altered the logic as the tests will fail. You can think about unit tests as a snapshot of the system behavior at the time they were authored.

There are a few key objectives I believe are required for effective use of unit testing a project.

  1. Tests must be ran within the Continuous Integration pipeline

    Broken code cannot be integrated with the rest of the code base, it should be stopped as early as possible. Ideally every contributor is running the test suite locally before committing changes but this not guaranteed, to assist in this a git pre-commit hook can be used.

  2. Running tests must be fast

    If the test suite runs fast then contributors are more likely to run the tests periodically and not see them as a burden, additionally reducing the Continuous Integration runtime is always good

  3. Code base must be designed in a way that makes it testable

    This may seem obvious but knowing this and applying it sometimes becomes tricky, we will delve deeper into how to achieve this in the next section. This usually comes down to following best practices, minimizing coupling, etc.

  4. Unit tests need to be actively developed

    Wether this means retroactively implementing tests for existing behavior or writing tests for new functionality, having the tests is crucial, what is the point if you are not increasing code coverage

Dependency Injection

Take the following example which does not implement dependency injection.

...

type A struct {}

func (a *A) DoSomething() int {
    fmt.Println("something")
}

type B struct {
    A A
}

func NewB() *B {
    return &B{
        A: A{}     
    }
}

func main() {
    b := NewB()
    b.A.DoSomething()
}

We have a constructor for B however we have no control regarding it’s A field, additionally this is tightly coupled and limits our testing abilities.

For B to follow the dependency injection pattern only a few changes need to be made.

...
func NewB(A A) *B {
    return &B{
        A: A
    }
}
...

func main() {
    a := A{}
    b := NewB(a)
    b.A.DoSomething()
}

The constructor has now been updated to require A to be supplied and is used to set the value for the field, this is the injection, specifically constructor based dependency injection.

There are alternative methods for dependency injection depending on your language for example the Spring framework with java allows the injection to be done at runtime purely by supplying a configuration this adds a lot of mental overhead to development and is often “magic” this is called “auto wiring”, I personally prefer the constructor based approach due to these reasons.

The purpose of dependency injection is to decouple logic between components which promotes cohesion and the single responsibility principle. A real life practical example is that your code base relies on a specific dependency for database interaction, the author of the dependency has ceased development and does not support some desired functionality offered from your database. Using the dependency injection pattern you should be able to “drop in” a replacement without too much effort and perform testing to validate correctness.

The above examples reference the struct types, in the real world these should be interfaces to further decrease coupling within your project as we can leverage Go’s implicit implementation of interfaces.

Mocking

Mocking is the process of manually dictating the behaviour of a component within an application. It is used in testing to remove the reliance of the test suite on external dependencies such as 3rd party APIs and to also guarantee dependency output results, mocking speeds up test execution and makes the tests more reliable. There are some cases where mocking is required to gain test coverage especially for rare error conditions that come from networking faults for example.

Say we had a function that retrieved data from a database, in the test suite we could intercept the call to the database and return a static response. This removes all the effort required for running the database, configuration, populating data, etc. but still provides a way to test the application logic.

The Application

OK, with all the required background knowledge regarding unit testing and dependency injection out of the way we can now start getting into the details.

For the purpose of this post we need an application, we are going to test a fictitious service which given a location will output a recommendation on whether or not wearing a hat is a good idea based on the day’s weather forecast.

Application Composition

The application is comprised of two main components.

  • WeatherService

    Retrieves weather forecast data via a REST API from a external weather data provider using the supplied location

  • WeatherRecommendation

    Uses WeatherService to retrieve the forecast and returns the appropriate recommendation

The interface definitions would be as follows


type WeatherService interface {
    GetTodaysForecast(lat string, long string) (string, error)
}

type WeatherRecommendation interface {
    ShouldAHatBeWorn() (bool, error)

}

The implementation structs


type weatherServiceImpl struct {
    dataProvider ExternalWeatherDataProvider
}

type weatherRecommendationImpl struct {
    lat string,
    long string,
    weatherService WeatherService
}

The constructors


func NewWeatherService(dataProvider ExternalWeatherDataProvider) WeatherService {
    return &weatherServiceImpl{
        dataProvider: dataProvider
    }
}

func NewWeatherRecommendation(lat string, long string, weatherService WeatherService) WeatherRecommendation {
    return &weatherRecommendationImpl{
        lat: lat,
        long: long,
        weatherService: weatherService
    }
}

Testing

For the sake of simplicity we will just be looking at unit testing the WeatherRecommendation implementation you could consider this a Test Driven Development (TDD) approach as I have not provided the implementation details of ShouldAHatBeWorn.

Mocking will be achieved in two ways, the first being using dependency injection and a custom implementation and the other method will utilize uber-go/mock

Native Mocking

To achieve mocking with no additional dependencies a custom implementation (struct) will be defined which implements the WeatherService interface.

Mock definition


type MockWeatherService struct {
    weatherForecast string
    err error
}

func (ws *MockWeatherService) GetTodaysForecast(lat string, long string) (string, error) {
    return ws.weatherForecast, ws.err
}

func NewMockWeatherService(forecast string, err error) MockWeatherService {
    return MockWeatherService{
        weatherForecast: forecast,
        err: err
    }
}

The above definition allows us to create a mocked weather service and seed it with the return values, we can then make WeatherRecommendation call this mock using dependency injection.

Putting this together we can define the following tests.


func TestHatRecommendedOnSunnyDay(t *testing.T) {
    mockWeatherService := NewMockWeatherService("sunny", nil)
    weatherRecommendation := NewWeatherRecommendation("", "", mockWeatherService)

    shouldWearAHat, err := weatherRecommendation.ShouldAHatBeWorn()

    if err != nil {
        t.Errorf("unexpected error")
    }

    if !shouldWearAHat {
        t.Errorf("expected to wear a hat to be recommended")
    }
}

func TestHatNotReccomendedOnStormsDay(t *testing.T) {
    mockWeatherService := NewMockWeatherService("storms", nil)
    weatherRecomendation := NewWeatherRecomendation("", "", mockWeatherService)

    shouldWearAHat, err := weatherRecomendation.ShouldAHatBeWorn()

    if err != nil {
        t.Errorf("unexpected error")
    }

    if shouldWearAHat {
        t.Errorf("expected to wear a hat to not be reccomended")
    }
}

func TestErrorIsBubbledUp(t *testing.T) {
    expectedError := errors.New("connection issue")
    mockWeatherService := NewMockWeatherService("", expectedError)
    weatherRecomendation := NewWeatherRecomendation("", "", mockWeatherService)

    shouldWearAHat, err := weatherRecomendation.ShouldAHatBeWorn()


    if err != expectedError {
        fmt.Errorf("expected error from weather service to be returned") 
    }

    if shouldWearAHat {
        t.Errorf("expected to wear a hat to not be reccomended")
    }
}

Mocking with uber-go/mock

The native approach to creating mocks can easily become arduous and could be an impediment for writing unit tests which is why I prefer to use uber-go/mock to automate the mock generation.

The mock generation can be integrated into your Continuous Integration pipeline to remove any mental overhead.

uber-go/mock has many possible configurations when generating mocks so I will not go into specifics (refer to the README) but the general gist is install the binary, specify interfaces to generate mocks for and then the output path. To make this process even more streamlined go generate can be used, which essentially embeds the command as a comment in the source code. You can then execute go generate ./.. to run all generate commands across your project, when this is ran in your Continuous Integration pipeline it ensures that the mocks are always up to date with any interface changes.

Rewriting the native tests to use uber-go/mock require the following steps; obtain a new controller, create a mock implementation, define behaviour, run your application code.


const (
    lat = "-28.016666"
    long = "153.399994"
)

func TestHatReccomendedOnSunnyDay(t *testing.T) {
    ctrl := gomock.NewController(t)
    mockWeatherService := NewMockWeatherService(ctrl)
    mockWeatherService.Expect().GetTodaysForecast(lat, long).Return("sunny", nil)

    weatherRecomendation := NewWeatherRecomendation(lat, long, mockWeatherService)

    shouldWearAHat, err := weatherRecomendation.ShouldAHatBeWorn()

    if err != nil {
        t.Errorf("unexpected error")
    }

    if !shouldWearAHat {
        t.Errorf("expected to wear a hat to be reccomended")
    }
}

func TestHatNotReccomendedOnStormsDay(t *testing.T) {
    ctrl := gomock.NewController(t)
    mockWeatherService := NewMockWeatherService(ctrl)
    mockWeatherService.Expect().GetTodaysForecast(lat, long).Return("storms", nil)

    weatherRecomendation := NewWeatherRecomendation(lat, long, mockWeatherService)

    shouldWearAHat, err := weatherRecomendation.ShouldAHatBeWorn()

    if err != nil {
        t.Errorf("unexpected error")
    }

    if shouldWearAHat {
        t.Errorf("expected to wear a hat to not be reccomended")
    }
}

func TestErrorIsBubbledUp(t *testing.T) {
    expectedError := errors.New("connection issue")

    ctrl := gomock.NewController(t)
    mockWeatherService := NewMockWeatherService(ctrl)
    mockWeatherService.Expect().GetTodaysForecast(lat, long).Return("", expectedError)

    weatherRecomendation := NewWeatherRecomendation("", "", mockWeatherService)

    shouldWearAHat, err := weatherRecomendation.ShouldAHatBeWorn()


    if err != expectedError {
        fmt.Errorf("expected error from weather service to be returned") 
    }

    if shouldWearAHat {
        t.Errorf("expected to wear a hat to not be reccomended")
    }
}

Above we only used uber-go/gomock to create static responses, it is far more powerful than this however for example setting a static response for a certain number of times which asserts that the associated function was called this amount of times only (the same for not called), this is helpful to validate optimization efforts. If a static response is not sufficient for your use case you can even stub the method, that is to say override the original function with a custom defined function.

Conclusion

Mocking unit tests is a fantastic way to increase code quality and confidence within the system however is not a replacement for integration testing and end to end tests, the unit tests only prove that your component is “valid” but not neccesary the whole scenario in a larger system. The way I see it is that unit tests provide guard rails for further development and refactoring by defining the expected logically behaviour, integration tests validate application logic is valid with the use of external dependencies (database, cache, external APIs, etc) and end-to-end tests validate the end user scenarios.