zeus@home:~$

Go testing 101

Testing. It has been said and written a lot about testing in software engineering, and some new trends and strategies appeared, especially in the early 2000s. There is no silver bullet for doing it perfectly, and it has been one of the topics that generated a lot of discussions about how to do it well and how much you should test your code. In this post, I’m not going to talk about abstract things or how they do it well, I just wanna show all the testing tools that I know and use for testing my Golang applications. This is mostly aimed at Golang beginners. If you have been using GO for a while, this will probably sound too basic for you. Let’s start.



Unit testing

I’ll try to use very simple code examples because the purpose of this is to show testing, not clever applications. Imagine that we have a function that sums 2 numbers:

    func sum(x, y int) int {
        return x + y
}

The first step to test this in Go is to create a function, inside a file with the suffix _test.go, that receives an argument of type *testing.T, executes the code, and asserts the output. Here are some examples:

package main

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestSum(t *testing.T) {
    result := sum(10, 25)
    expected := 35

    assert.Equal(t, expected, result)
}

func TestSumNegative(t *testing.T) {
    result := sum(-5, -7)
    expected := -12

    assert.Equal(t, expected, result)
}

func TestSumMixed(t *testing.T) {
    result := sum(-14, 28)
    expected := 14

    assert.Equal(t, expected, result)
}

This is not much different from how you do it in some other languages. Maybe the most confusing thing is the need for the *testing.T argument, but you get used to it eventually.

For the sake of readability, I’ve used the assert package, but if you don’t want to use external dependencies you can do it like this:

    if if reflect.DeepEqual(expected, result){
        t.Fatalf("Test fail: want %d, got %d", expected, result)
    }

reflect.DeepEqual is not always needed, but if you want to compare the inner values and not the struct, it is advisable to use it to avoid false negatives.

If one of the returning values of the function is a error, it is very common to make the assert on the error to check if there is an error or not and if it is the expected error.

Table driven tests

This is a very common pattern in Go testing when you want to test several unit tests. It consists of creating an array of structs with the parameters needed for the tests, like name, inputs, outputs, etc., and iterating it to execute the function or method that we want to test. The unit tests above can be compacted in a table test like this:

package main

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestSumTable(t *testing.T) {
    type test struct {
        name     string
        input    [2]int
        expected int
    }

    tests := []test{
        {name: "Happy Path", input: [2]int{10, 25}, expected: 35},
        {name: "Negative", input: [2]int{10, 25}, expected: 35},
        {name: "Mixed", input: [2]int{10, 25}, expected: 35},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            result := sum(tc.input[0], tc.input[1])
            assert.Equal(t, tc.expected, result)
        })
    }
}



Mocks

There are some Golang packages related to mocking, but I think there are two more widely used by far, especially the first one:

  • mockery - Third-party package.
  • gomock - Part of Golang standard libraries.

Both of the packages above have documentation about how to use them, so I won’t get into the details of any specific library.

Mocks in Golang have the peculiarity that they need to be created over an existing interface, which means that you cannot mock a struct, and you should always receive and handle interfaces over concrete structs if you want to make extensive testing.

The strategies I used so far, consist of generating the Mock for the desired interfaces, including the package in the tests in which you want to use the mocks, writing the expectations and the returns, and finally asserting that the expectations have been met.

Golang’s community good practices recommend not create interfaces just for the sake of testing and use the real object instead, but I find this very confusing for a specific use case: testing external services.

Testing external services

If you want to test some piece of code that is making a request to an external service, it’s uncommon that you want to make a real connection when running your test suite, so if you want to test the code handling that request, you need to mock the response of this request. Over my experience with GO, I’ve seen two ways of doing this:

1. Mocking the server

Golang standard library provides a package for making this kind of mocking: httptest. The common workflow for doing tests to external services with this package is:

  • Create a handler that matches the http.Handler interface and returns the desired response.
  • Generate a new httpserver server injecting the handler created in the previous step.
  • Use the URL of this server instead of the service that your code is using. As you can imagine, the URL of the service needs to be configurable, which I don’t see it much of a problem because I think is a good practice to be able to inject these configurations.

I think that testing should be as simple as possible, and for me, this is a little of toil for each test that becomes worse as the test suite grows and adds noise to the testing code base. Furthermore, this option doesn’t allow you to assert call expectations easily, which I think is important in complex code that makes several calls to one or more services. For these reasons, I prefer the next option over this one.

2. Wrapping the call on an interface

This is the option that I usually implement because I think is simple and the -off is just adding an interface for connecting to the service, which I think is good for the design of the code in most of the scenarios.

For this case, you only have to add an interface on top of HTTP (or gRPC or whatever protocol you are using) and use the mock of that interface for the test. Let’s see how it works with an example.

Let’s imagine that we want to obtain a random number from an external API and the implementation is the following:

func GetRandomNum(url string) (int, error)
    resp, err := http.Get(url)
    if err != nil {
        logrus.Errorf("Error making the request to %v: %v", url, err)
        return 0, err
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        logrus.Errorf("Error parsing the response: %v", err)
        return 0, err
    }

    strInt := string(string(body))
    myInt, err := strconv.Atoi(strings.TrimSpace(strInt))

    if err != nil {
        logrus.Errorf("Error converting the response: %v", err)
        return 0, err
    }
    return myInt, nil
}

What we would need to add is an interface with this method, and convert the previous function to a method for a struct. For example something like this:

//go:generate mockery --case underscore --inpackage --name Random
type Random interface {
    GetRandomNum() (int, error)
}

type randomNumber struct {
    url
}

func NewRandomNumber(url string) Random {
    return randomNumber{url: url}
}

func (rn randomNumber) GetRandomNum() (int, error) {
    ...
}

And from here, you can generate the mock, on your IDE or in the CLI, and use it:

func TestGetRandomNumber(t *testing.T) {
    numberProvider := MockRandom{}

    numberProvider.On("GetRandomNumber").
        Once().
        Return(1, nil)

    result, err := numberProvider.GetRandomNum()

    numberProvider.AssertExpectations(t)
    assert.NoError(t, err)
    assert.Equal(t, 1, result)
}

The code above is only to exemplify in a simple way how to use the mock, but we don’t usually create a test for testing the mock and this is usually included in a bigger test case.

In line with this, if you are creating a public package that is exposed for external use, my advice is to provide the mock too. This way, the users of your code won’t have to do it themselves and, creating the mock is usually easier for you because you have the interface already defined in your code.

Racy tests

Golang makes concurrency quite simple and also provides a race detector for helping to test it. Let’s see how to use it following the previous example. Imagine that we want to get a random number from an API to return a random sum. We can do it with the following code:

func randomSum(ctx context.Context, numProvider Random) (int, error) {
    var x, y int
    var err error
    var mu sync.Mutex

    errGroup, _ := errgroup.WithContext(ctx)

    errGroup.Go(func() error {
        logrus.Info("Making request to random number API")

        mu.Lock()
        defer mu.Unlock()

        x, err = numProvider.GetRandomNum()
        logrus.Infof("Obtained random number: %d", x)
        return err
    })

    errGroup.Go(func() error {
        logrus.Info("Making request to random number API")

        mu.Lock()
        defer mu.Unlock()

        y, err = numProvider.GetRandomNum()
        logrus.Infof("Obtained random number: %d", y)
        return err
    })

    err = errGroup.Wait()
    if err != nil {
        fmt.Printf("Error getting randomg numbers: %v", err)
    }

    return sum(x, y), nil
}

If we want to test there are no race conditions on this, the most straightway strategy is to make many requests in parallel:

func TestRacy(t *testing.T) {
    mockRand := &MockRandom{}
    mockRand.On("GetRandomNum").Times(200).Return(rand.Int(), nil)
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()

            _, err := randomSum(context.TODO(), mockRand)
            assert.NoError(t, err)
        }()
    }
}

And then executing the go test command using the -race flag for race detection.

You can find a detailed explanation of this feature and more examples in this post from the Go blog.

Benchmark

Another cool feature that Golang provides is benchmarking. If you have a piece of code that you want to be performant or you just want to measure its execution, you can write a benchmark test. The differences are that you need to pass a *testing.B argument instead of *testing.T, and you need to iterate until the value of the N property of that argument. Also, we need to pass the -bench flag to the go test command. Here is a basic example using the sum function that we defined at the beginning of this post:

var (
    x = rand.Int()
    y = rand.Int()
)

func BenchmarkSum(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sum(x, y)
    }
}

Quite related to this, are the memory and CPU profiling options that GO provides. When running your tests, you can generate profiling files that you can visualize with pprof for example. Here is a simple example from the GO docs.

This may be a little out of scope for this post, but if you want to know more about profiling your application, this is a great post from Uber about how to do it.

Fuzzing

Fuzzing consists of repeating several times the same tests but varying the inputs, so we can test many different inputs at once.

This is supported in Golang natively quite recently, from version 1.18, but there are some libraries, before including it in GO, that provided this behavior like google/gofuzz.

For making these tests, you need to seed the inputs that you want to vary and use them in the .Fuzz argument function. Let’s see it with the example of the function that sums two integers:

func FuzzSum(f *testing.F) {
    f.Add(0, int(time.Now().UnixNano())) // Seed corpus
    f.Fuzz(func(t *testing.T, x int, y int) {
        rand.Seed(time.Now().UnixNano())
        result := sum(x, y)
        assert.Equal(t, x+y, result)
    })
}

As we want to vary two integers for this function, x and y, we need to seed them in the method .Add, which in this case receives two integers, and for the same reason we pass to .Fuzz a function with the signature func(*testing.T, int, int), that again contains two integers.

Here is the link to the Golang documentation for more extensive info.

Acceptance testing or end-to-end

These tests consist of testing the complete workflow on your application instead of specific components. These are usually the most difficult tests because they require a wider setup, the execution is slower than the unit tests and there are more components of your application involved because you are testing a complete workflow.

If your application does not receive any traffic and doesn’t have external dependencies, you can skip all the setup and write them the same way you write unit tests, although in a different folder for organizational hygiene. Nonetheless, if you receive traffic and/or connect to external systems, my advice is to run your application and test it from outside like you were a user, a kind of black-box testing.

First, you need to start your application and the dependencies. You have several options for this:

  1. Do it manually. Strongly misadvised.
  2. Write your own script.
  3. Dockerize in the traditional way Dockerfile and docker-compose.
  4. Dockerize using the Docker client for your language and start your application and dependencies as a pre/setup step on your tests. This is the GO one.

You don’t need to run all the dependencies of your application. I usually run the infrastructure dependencies, like the database or the cache, and I create a mock for the external dependencies listening to the same address that this external dependency.

Once we have everything we need running, we can run our tests against the application making external requests. There are several frameworks and tools for helping on this part. I use testify/suite because I find it simple, but there are others well known, like Ginkgo and Gomega, used for example on Kubernetes code base.

For this section, I won’t add an example to the post because it’ll add too much noise, but you can find it in this repository. As you can see, I start my toy application using Docker + docker-compose, I mock my external dependency and run it on the same port that the real one would be, and I run the tests.

Coverage report

Another great feature GO natively has, is the coverage report for the tests. Furthermore, it makes it quite simple to execute. Using the -cover option, when executing go test, will print a summary of the report in the standard output. However, if you want a complete coverage profile, you can use the -coverprofile=<output_file> option to generate a report you can visualize using go tool. For example, if you want to visualize in HTML format, you can run go tool cover -html=<output_file>. Again, we can find a more detailed and better explanation of this feature in the official documentation.

Final thoughts

Golang provides natively a lot of useful testing features and configuration, as we saw in this post, and there are even more that we didn’t go through to avoid extending too much (you can check more options on the official documentation). Also, there is a good ecosystem of third-party tools for helping us.

With this post, I’ve intended to provide a good base for understanding the options that we have when testing our GO code. In fact, this is more than I used in my day-to-day work, which I can reduce to unit and end-to-end testing mostly. However, I wanted to show most of the useful options that I know, so this can serve as a starting point to begin or improve your testing in GO.

If you want to have the examples I used in this post, I’ve stored them in this repository. I hope the readers find this material interesting. Happy testing! 😄