The World of Testing in Go

Lesezeit: 8 Min, veröffentlicht am 06.06.2019
The World of Testing in Go

Why do we even write tests?

Testing your code is an essential part of software engineering. Whether you write code first and then tests or the other way around, there should be no discussion about writing tests. The expectation of testing should not be to find bugs but to have a stable code base that can be easily extended and refactored. As such, tests provide a safety net for the developer.

There are different types of tests to write. One classification that cannot be missed in every article about testing is the testing pyramid.


Testing Pyramid - Image from https://martinfowler.com/articles/practical-test-pyramid.html

Unit tests provide the base of your testing strategy. A unit test tests a single unit of code which usually is a class or in Go simply a file. Next are service tests also called integration tests, which test multiple units interacting with each other. Why we need integration tests is wonderfully shown by this Tweet:

Simply testing each unit is not enough, as in reality, these units never exist in isolation. But integration tests take longer to execute because there needs to be additional bootstrapping of components so the amount of integration tests should not exceed the amount of unit tests. The third layer of testing is UI tests. They test the full application with the UI to ensure that not only the logic but also the UI interactions properly work. They take a long time to execute because the complete application needs to run with all components and UI testing frameworks only work with a lot of timeouts between clicks.

For this article, we only focus on unit tests, as Go is mostly used for web servers or command-line tools where there is no graphical UI.

Simple unit tests

A basic test is simply executing a function and comparing the actual value with the expected value. One more thing we need is a test runner. The core library of Go already has a lightweight test-framework built-in. Tests are run with the command go test. You just need to include the library testing and add the parameter variable *testing.T to your test methods. Tests methods start with prefix Test and are inside test files which need to end with the suffix _test.go. A test is considered a failure, if either the t.Error or t.Fail are called. See the following example for a very simple test:

package test

import "testing"

func Multiply(a, b int) {
    return a * b
}

func TestMultiply(t *testing.T) {
    expected := 42
    actual := Multiply(7, 6)

    if actual != expected {
        t.Errorf("expected %s but got %s", expected, actual)
    }
}

You can see how easy it is to write a unit test in Go. There is not a lot of setup necessary. One issue is that this leads to a lot of boilerplate if we need to test the same function with multiple cases. Here, a pattern called table-driven testing can help a lot.

Table all the tests

To define multiple test cases for one function, we can create a table as an array of structs. One struct is one test case e.g. in our previous example the two numbers 7 and 6 where we expect the result of the multiplication to be 42. With these table-driven tests, we can save a lot of test code as we can reuse the assertion code for all the test cases. Furthermore, it is easy to add new test cases.

The following code example shows the table-driven version of the previous example.

func TestMultiply(t *testing.T) {
    testCases := []struct {
        a  int
        b  int
        expected int
    }{
        {0, 5, 0},
        {1, 5, 5}, 
        {7, 6, 42},
        {6, 7, 42},
    }
    for _, tc := range testCases {
        actual := Multiply(tc.a, tc.b)
        if actual != tc.expected {
            t.Errorf("expected %s but got %s", tc.expected, actual)
        }
    }
}

One small issue with the code is that it creates one test for all test cases which means the test halts on the first error. Usually you want to see all failing test cases. To create a separate test for each test case, we can use the feature subtests.

The following code example shows the same test written with subtests.

func TestMultiply(t *testing.T) {
    testCases := []struct {
        a  int
        b  int
        expected int
    }{
        {0, 5, 0},
        {1, 5, 5}, 
        {7, 6, 42},
        {6, 7, 42},
    }
    for _, tc := range testCases {
        t.Run(fmt.Sprintf("Multiply %s with %s", tc.a, tc.b), func(t *testing.T) {       
            actual := Multiply(tc.a, tc.b)
            if actual != tc.expected {
                t.Errorf("expected %s but got %s", tc.expected, actual)
            }
        }
    }
}

This will create a subtest for each test case and every failing subtest is displayed in the test result log. The method t.Run takes the name of the subtest as first parameter and the test function to execute as second parameter. You should always try to write subtests when using multiple test cases as the result output is heavily improved.

Even though we can save a lot of test code with this technique, it is still a lot of boilerplate to write this structure for each method we want to test. A small command-line tool called gotests helps here as it generates the table-driven test boilerplate for all methods we want to test. There also exists plugins for all your favorite IDEs like Goland or Visual Studio Code.

Cover all your bases

Just writing tests without a goal does not help a lot. Most of the time, we measure the percentage of code that is covered by our tests. While 100% test coverage does not necessarily signals that you write good or enough tests, it is a good indicator that you are in the right direction. The Go CLI already includes a command to calculate test coverage. Just run your tests with the command-line flag -cover e.g. go test -cover. This gives you a quick overview of your test coverage.

For more detailed coverage information, you can generate a profile with the flag -coverprofile=c.out. This profile can then be displayed as html in a browser with the cover tool: go tool cover -html=c.out. It shows all tracked code lines per file which can help you identify the lines of code you still need to write a test for.

When you need more convenience

Writing your own assertions for every test can be quite cumbersome. Most likely throughout your whole project you will write your own assertion library in the process. As such, there are already many assertion libraries written for Go. One that stands out in terms of functionality and is still actively developed is testify. The assert library contains equality assertions and especially helpful are list assertions. Additionally, it contains a library for test suites with setup and teardown which are commonly used in object oriented languages.

This code example shows the previous running example written with testify:

func TestMultiply(t *testing.T) {
    assert := assert.New(t)

    assert.Equal(0, Multiply(0,5), "they should be equal")
    assert.Equal(5, Multiply(1,5), "they should be equal")
    assert.Equal(42, Multiply(7,6), "they should be equal")
    assert.Equal(42, Multiply(6,7), "they should be equal")

}

We first need to initialize a assert object which we then can reuse multiple times for our assertions. As we can just use the Equal method, our code is reduced to some lines where we give the expected and actual value.

Another assertion library in the style of behavior-driven testing is Ginkgo. Here, tests are structured similar to a story describing what the method should do.
You first describe a test scenario wrapped in a Describe method under which you then write all test cases in a separate It method.

The following code example shows the previous example written with Ginkgo:

var _ = Describe("Multiplication", func() {
    Context("with 0", func() {
        It("should be 0", func() {
            Expect(Multiply(1,5)).To(Equal(0))
        })
    })

    Context("with 1", func() {
        It("should be the other parameter", func() {
            Expect(Multiply(1,5)).To(Equal(5))
        })
    })
    It("should be commutative", func() {
            Expect(Multiply(7,6)).To(Equal(42))
            Expect(Multiply(6,7)).To(Equal(42))
    })
})

These tests have the advantage of being way more descriptive. It helps the developer to understand the purpose of the test and can also help to understand the functionality of the application.

Both of these styles of writing tests are valid ways and it is up to the developer or team to decide which one to use. These libraries give you a quick start in writing compact tests. But keep in mind that you might lose some flexibility as you are depending on the API of them. Pure go tests have the advantage of being very similar to just writing code which also means that there is no need to understand the APIs of these convenience libraries.

Even more advanced techniques

There are even more testing techniques that can be of interest but explaining them would go way beyond this quick look into testing in Go. Still, we would like to introduce them to you briefly.

One is testing http servers using the http testing API httptest which is also included in the core library. When you use the standard http handler included in the core library, this testing API is the obvious counterpart. You can create test requests and record the responses using the ResponseRecorder which implements http.ResponseWriter.

Another important technique is mocking. Sometimes we need to provide an empty or fake implementation of an interface which is used in another method to abstract away from its real implementation. We could implement a fake ourselves but it introduces a lot of boilerplate. As a result, we can use a library like mockery or counterfeiter to automatically generate a mock for our interfaces which can then be used in our unit tests.

A not so often used technique is random or fuzz testing. The idea is to randomly generate test cases for a function to find edge cases which you normally would forget about. Two libraries in go for that are go-fuzz and gofuzz. While this cannot be used for methods with a lot of specific business logic, it can be useful to test things like serialization of objects or mathematical functions.

Summary

In summary, testing in Go can be done without the need of adding third party libraries as the core library already includes the necessary testing utilities. Table-driven tests can reduce the test code and additionally using assertion libraries can help write tests more quickly. There are more advanced techniques to look at like http testing, mocking or random testing which we might write about in another future blog post.

Sources:

Tags

Verfasst von:

Foto von Markus

Markus

Markus ist Full Stack Developer bei cosee und treibt sich sowohl im Front- als auch im Backend rum.