Ayooluwa Isaiah I'm a software developer from Nigeria with a keen interest in web technologies, security, and performance. I'm currently working on my own products and teaching programming via my website freshman.tech.

A deep dive into unit testing in Go

9 min read 2676

deep-dive-unit-testing-go

In unit testing, developers test individual functions, methods, modules, and packages to verify their correctness. Unit testing helps to find and fix bugs early in the development cycle, and it prevents regressions when refactoring. A good unit test can also serve as a form of documentation for developers who are new to the project.

In this tutorial, we’ll cover how to write unit tests in Go using the built-in testing package and several external tools. By the end of this article, you’ll understand concepts like table driven tests, dependency injection, and code coverage.

Let’s get started!

Writing your first test in Go

To understand testing in Go, we’ll write a basic program that computes the product of two integers. Then, we’ll write a test that verifies the correctness of its output.

First, create a directory on your file system and navigate into it. In the directory root, create a file called integers.go and add the following code:

// integers.go
package main

import (
    "fmt"
)

// Multiply returns the product of two integers
func Multiply(a, b int) int {
    return a * b
}

Let’s write a test to verify that the Multiply() function works correctly. In the current directory, create a file called integers_test.go and add the following code to it:

// integers_test.go
package main

import "testing"

func TestMultiply(t *testing.T) {
    got := Multiply(2, 3)
    want := 6

    if want != got {
        t.Errorf("Expected '%d', but got '%d'", want, got)
    }
}

Anatomy of a Go test

The convention for naming test files in Go is to end the file name with the _test.go suffix and place the file in the same directory as the code it tests. In the example above, the Multiply function is in integers.go, so its tests are placed in integers_test.go.

Note that Go does not ship test files in any binaries that it produces because they are not needed for the code to run. In Go, a test function must always use the following signature:

func TestXxx(*testing.T) 

A test’s name begins with the Test prefix, followed by the name of the function being tested, Xxx. It takes a single argument, which is a pointer of type testing.T. The type exports several methods for tasks like reporting errors, logging intermediate values, and specifying helper methods.

In our example in the previous section, the got variable inside the TestMultiply() function is assigned to the result of the Multiply(2, 3) function call. want is assigned to the expected result 6.

We made a custom demo for .
No really. Click here to check it out.

The latter part of the test checks if the values of want and got are equal. If not, the Errorf() method is invoked, failing the test.

Running Go tests

Now, let’s use the go test command to run our test in the terminal. As long as Go is installed, the go test command is already available on your machine.

The go test command compiles the sources, files, and tests found in the current directory, then runs the resulting test binary. When testing is done, a summary of the test, either PASS or FAIL, will be printed to the console, as seen in the code block below:

$ go test
PASS
ok      github.com/ayoisaiah/random 0.003s

When you use go test as above, caching is disabled, so the tests are executed every time.

You can also opt into package list mode by using go test ., which caches successful test results and avoids unnecessary reruns.

You can run tests in a specific package by passing the relative path to the package, for example, go test ./package-name. Additionally, you can use go test ./... to run the tests for all the packages in the codebase:

$ go test .
ok      github.com/ayoisaiah/random (cached)

If you append the -v flag to go test, the test will print out the names of all the executed test functions and the time spent for their execution. Additionally, the test displays the output of printing to the error log, for example, when you use t.Log() or t.Logf():

$ go test -v
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.002s

Let’s cause our test to fail by changing want to 7. Run go test once again, and inspect its output:

$ go test -v
--- FAIL: TestMultiply (0.00s)
    integers_test.go:10: Expected '7', but got '6'
FAIL
exit status 1
FAIL    github.com/ayoisaiah/random 0.003s

As you can see, the test failed, and the message passed to the t.Errorf() function is present in the failure message. If you return the want value to 6, the test will pass once again.

Table driven tests in Go

The test example from above contains only a single case. However, any reasonably comprehensive test would have multiple test cases, ensuring that each unit of code is sufficiently audited against a range of values.

In Go, we use table driven tests, which allow us to define all our tests cases in a slice, iterate over them, and perform comparisons to determine if the test case succeeded or failed:

type testCase struct {
    arg1 int
    arg2 int
    want int
}

func TestMultiply(t *testing.T) {
    cases := []testCase{
        {2, 3, 6},
        {10, 5, 50},
        {-8, -3, 24},
        {0, 9, 0},
        {-7, 6, -42},
    }

    for _, tc := range cases {
        got := Multiply(tc.arg1, tc.arg2)
        if tc.want != got {
            t.Errorf("Expected '%d', but got '%d'", tc.want, got)
        }
    }
}

In the code snippet above, we use the testCase struct to define the inputs for each test case. The arg1 and arg2 properties represent the arguments to Multiply, while want is the expected result for the test case.

The cases slice is used to set up all the test cases for the Multiply function. Note that the property names are omitted for simplicity.

To test each case, we need to iterate over the cases slice, pass arg1 and arg2 from each case to Multiply(), then confirm if the return value is equal to what want specified. We can test as many cases as needed using this setup.

If you run the test again, it will pass successfully:

$ go test -v
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
PASS
ok      github.com/ayoisaiah/random     0.002s

Signaling test failure

In the examples above, we’ve used the t.Errorf() method to fail tests. Using t.Errorf() is equivalent to invoking t.Logf(), which logs text to the console either on test failures or when the -v flag is provided, followed by t.Fail(), which marks the current function as failed without halting its execution.

Using t.Errorf() prevents a test failure when we halt the function, allowing us to gather more information to fix the problem. Additionally, in a table driven test, t.Errorf() allows us to fail a specific case without affecting the execution of other tests.

If a test function cannot recover from a failure, you can stop it immediately by invoking t.Fatal() or t.Fatalf(). Either method marks the current function as failed, stopping its execution immediately. These methods are equivalent to calling t.Log() or t.Logf(), followed by t.FailNow().

Using subtests

Using a table driven test is effective, however, there is one major flaw – the inability to selectively run an individual test case without running all test cases.

One solution to this problem is to comment out all the test cases that are irrelevant in the moment and uncomment them again later on. However, doing so is tedious and error-prone. For this scenario, we’ll use a subtest!

In Go 1.7, we can split each test case into a unique test that is run in a separate goroutine by adding a Run() method to the testing.T type. The Run() method takes the name of the subtest as its first argument and the subtest function as the second. You can use the test name to identify and run the subtest individually.

To see it in action, let’s update our TestMultiply test, as shown below:

func TestMultiply(t *testing.T) {
    cases := []testCase{
        {2, 3, 6},
        {10, 5, 50},
        {-8, -3, 24},
        {0, 9, 0},
        {-7, 6, -42},
    }

    for _, tc := range cases {
        t.Run(fmt.Sprintf("%d*%d=%d", tc.arg1, tc.arg2, tc.want), func(t *testing.T) {
            got := Multiply(tc.arg1, tc.arg2)
            if tc.want != got {
                t.Errorf("Expected '%d', but got '%d'", tc.want, got)
            }
        })
    }
}

Now, when you run the tests with the -v flag, each individual test case will be reported in the output. Because we constructed the name of each test from the values in each test case, it’s easy to identify a specific test case that failed.

To name our test cases, we’ll add a name property to the testCase struct. It’s worth noting that the TestMultiply function does not finish running until all its subtests have exited:

$ go test -v
=== RUN   TestMultiply
=== RUN   TestMultiply/2*3=6
=== RUN   TestMultiply/10*5=50
=== RUN   TestMultiply/-8*-3=24
=== RUN   TestMultiply/0*9=0
=== RUN   TestMultiply/-7*6=-42
--- PASS: TestMultiply (0.00s)
    --- PASS: TestMultiply/2*3=6 (0.00s)
    --- PASS: TestMultiply/10*5=50 (0.00s)
    --- PASS: TestMultiply/-8*-3=24 (0.00s)
    --- PASS: TestMultiply/0*9=0 (0.00s)
    --- PASS: TestMultiply/-7*6=-42 (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.003s

Measuring code coverage

Code coverage counts the lines of code that are successfully executed when your test suite is running, representing the percentage of your code covered by your test suite. For example, if you have a code coverage of 80 percent, it means that 20 percent of the codebase is lacking tests.

Go’s built-in code coverage method

Go provides a built-in method for checking your code coverage. Since Go v1.2, developers can use the -cover option with go test to generate a code coverage report:

$ go test -cover
PASS
coverage: 100.0% of statements
ok      github.com/ayoisaiah/random 0.002s

We’ve managed to achieve 100 percent test coverage for our code, however, we’ve only tested a single function comprehensively. Let’s add a new function in the integers.go file without writing a test for it:

// integers.go

// Add returns the summation of two integers
func Add(a, b int) int {
  return a + b
}

When we run the tests again with the -cover option, we’ll see coverage of just 50 percent:

$ go test -cover
PASS
coverage: 50.0% of statements
ok      github.com/ayoisaiah/random 0.002s

Examining our codebase

Although we know what percentage of our codebase is covered, we don’t know what parts of our codebase aren’t covered. Let’s convert the coverage report to a file using the --coverprofile option so we can examine it more closely:

$ go test -coverprofile=coverage.out
PASS
coverage: 50.0% of statements
ok      github.com/ayoisaiah/random 0.002s

In the code block above, the tests run as before, and code coverage is printed to the console.
However, the test results are also saved to a new file called coverage.out in the current working directory. To study these results, let’s run the following command, which breaks the coverage report down by function:

$ go tool cover -func=coverage.out
github.com/ayoisaiah/random/integers.go:4:    Multiply    100.0%
github.com/ayoisaiah/random/integers.go:9:    Add     0.0%
total:                            (statements)    50.0%

The code block above shows that the Multiply() function is fully covered, while the Add() function has only 50 percent coverage overall.

HTML coverage method

Another way to view the results is through an HTML representation. The code block below will open the default web browser automatically, showing the covered lines in green, uncovered lines in red, and uncounted statements in grey:

$ go tool cover -html=coverage.out

Using the HTML coverage method makes it easy to visualize what you haven’t covered yet. If the package being tested has multiple files, you can select each file from the input on the top right to see its coverage breakdown:

HTML Coverage Method Visual Output

Let’s get the code coverage back to 100 percent by adding a test for the Add() function, as shown below:

func TestAdd(t *testing.T) {
    cases := []test{
        {1, 1, 2},
        {7, 5, 12},
        {-19, -3, -22},
        {-1, 8, 7},
        {-12, 0, -12},
    }

    for _, tc := range cases {
        got := Add(tc.arg1, tc.arg2)
        if tc.want != got {
            t.Errorf("Expected '%d', but got '%d'", tc.want, got)
        }
    }
}

Running the tests again should display a code coverage of 100 percent:

$ go test -cover
PASS
coverage: 100.0% of statements
ok      github.com/ayoisaiah/random/integers    0.003s

Running a specific test

Let’s say that you have many test files and functions, but you want to isolate only one or a few to execute. We can do so using the -run option. For example, if we want to run only the tests for the Add function, we’ll pass the test function name as an argument to -run:

$ go test -v -run=TestAdd
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/ayoisaiah/random/integers    0.003s

As you can see from the output above, only the TestAdd method was executed. Note that the argument to -run is interpreted as a regular expression, so all the tests that match the provided regex will be run.

If you have a set of test functions that begin with the same prefix, like TestAdd_NegativeNumbers and TestAdd_PositiveNumbers, you can run them in isolation by passing the prefix, TestAdd, to -run.

Now, let’s assume that we only want to run TestAdd and TestMultiply, but we have other test functions. We can use a pipe character to separate their names in the argument to -run:

$ go test -v -run='TestAdd|TestMultiply'
=== RUN   TestMultiply
--- PASS: TestMultiply (0.00s)
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/ayoisaiah/random/integers    0.002s

You can also run a specific subtest by passing its name to -run. For example, we can run any of the subtests in the TestMultiply() function, as shown below:

$ go test -v -run='TestMultiply/2*3=6'
=== RUN   TestMultiply
=== RUN   TestMultiply/2*3=6
--- PASS: TestMultiply (0.00s)
    --- PASS: TestMultiply/2*3=6 (0.00s)
PASS
ok      github.com/ayoisaiah/random 0.003s

Dependency injection

Let’s assume we have a function that prints some output to the console, as shown below:

// printer.go
func Print(text string) {
    fmt.Println(text)
}

The Print() function above outputs its string argument to the console. To test it, we have to capture its output and compare it to the expected value. However, because we have no control over the implementation of fmt.Println(), using this method won’t work in our case. Instead, we can refactor the Print() function, making it easier to capture its output.

First, let’s replace the call to Println() with a call to Fprintln(), which takes an io.Writer interface as its first argument, specifying where its output should be written. In our example below, this location is specified as os.Stdout. Now, we can match the behavior provided by Println:

func Print(text string) {
    fmt.Fprintln(os.Stdout, text)
}

For our function, it doesn’t matter where we print the text. Therefore, instead of hard-coding os.Stdout, we should accept an io.Writer interface and pass that to fmt.Fprintln:

func Print(text string, w io.Writer) {
    fmt.Fprintln(w, text)
}

Now, we can control where the output of the Print() function is written, making it easy to test our function. In the example test below, we’ll use a buffer of bytes to capture the output of Print(), then compare it to the expected result:

// printer_test.go
func TestPrint(t *testing.T) {
    var buf bytes.Buffer

    text := "Hello, World!"

    Print(text, &buf)

    got := strings.TrimSpace(buf.String())

    if got != text {
        t.Errorf("Expected output to be: %s, but got: %s", text, got)
    }
}

When utilizing Print() in your source code, you can easily inject a concrete type and write to the standard output:

func main() {
    Print("Hello, World!", os.Stdout)
}

Although the example above is quite trivial, it illustrates one method for moving from a specialized function to a general-purpose one, allowing for the injection of different dependencies.

Conclusion

Writing unit tests ensures that each unit of code is working correctly, increasing the chance that your application as a whole will function as planned.

Having adequate unit tests also comes in handy when refactoring by helping to prevent regressions. The built-in testing package and the go test command provide you with considerable unit testing capabilities. You can learn more by referring to the official documentation.

Thanks for reading, and happy coding!

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Ayooluwa Isaiah I'm a software developer from Nigeria with a keen interest in web technologies, security, and performance. I'm currently working on my own products and teaching programming via my website freshman.tech.

Testing accessibility with Storybook

One big challenge when building a component library is prioritizing accessibility. Accessibility is usually seen as one of those “nice-to-have” features, and unfortunately, we’re...
Laura Carballo
4 min read

Leave a Reply