Unit Testing in Go

Unit Testing in Go

Unit testing is very common in Go (Golang) code. Many of the types of applications that Go is frequently used for are very easy to write great unit tests for. However, if you are new to the language or new to unit testing in general, then there are a few things that may help to steer you in the right direction. This article focuses on helping get you started writing unit tests and learning about some of the best practices related to testing.

If you are new to unit testing in general, you might want to check out our post on the benefits of unit testing.

images/unit-testing-in-go.jpg

Getting started

Unlike most other languages I have worked with, Go has a built-in test runner and framework for standard language tooling. The easiest way to run unit tests for your project is using a command like go test ./... from the command line. Running this command will discover and run any tests within your current directly or their subdirectories.

Tests must be in files separate from your main package code and end with the suffix _test.go. A test function is identified by starting with func Test . This is what one of the most simple tests would look like:

// hello.go - This is the code to be tested
package hello

func HelloWorld() string {
    return "hello world"
}
// hello_test.go - This is the unit test file
package hello_test

import (
    "fmt"
    "testing"
    "github.com/PullRequestInc/pkg/hello"
)

func TestHelloWorld(t *testing.T) {
    actual := hello.HelloWorld()
    if actual != "hello world" {
        t.Errorf("expected 'hello world', got '%s'", actual)
    }
}

The above test would pass as long as HelloWorld() always returns the correct string. However, if it ever failed, then it would print out the failure message from t.Errorf.

Unique to Go

If you are used to unit testing in other languages, there are a couple of things that will seem unique to writing unit tests in Go.

For one you will notice in the example above that the testing seems somewhat “crude” in that you perform the check yourself and then report an error message. There are not built in helpers for things like t.assertEquals(expected, actual). This is because Go tends to err on the side of minimalism and providing the basics that you need to perform a task in one way. This helps the language maintain backward compatibility for a long time and focus on optimizing and improving these core functionalities over time.

That being said, there is a great third-party library widely used for performing assertions of different types, testify. Testify has an assert and require package. The assert will fail the test on the first failure it encounters, similar to the built-in t.Fail. Where require will just report an error and continue the test similar to t.Error. I personally really like using the assert package and think that it fits in nicely with the built-in Go test tooling and framework. Your tests' overall structure will still be the same, except you will have some assertion helpers to make things slightly fewer lines of code. The example above would become:

assert.Equal(t, "hello world", hello.HelloWorld())

Go tests are also aimed at being similar to how you write standard Go code. This way, you can use and improve on your techniques for writing normal Go code as you write tests. In many other languages, the way you write tests feels completely different from writing the standard code (for example, BDD tests). However, if you want to use BDD testing, there are some third-party frameworks available such as Ginkgo. Although you are just getting started, I recommend avoiding using large third-party frameworks like this as most teams and projects use the standard testing framework for Go. There are also some features of the standard test framework that do not always have as good of compatibility with these other frameworks, like the concurrency testing mode.

Setup and Teardown

As we pointed out above, the Go tests are straightforward and just consist of a function. Many other test frameworks you may be used to have features like setUp methods that get run before every test and tearDown methods that get run after every test. This is one of the reasons that some people may be enticed to go with a third-party framework like Ginkgo or others at first. However, you can achieve the same functionality with Go tests.

The pattern for this in Go is referred to as “table-driven tests”. Here is how you would achieve a setUp and tearDown shared by multiple similar tests.

// hello_test.go
package hello_test

import (
    "fmt"
    "testing"
    "github.com/PullRequestInc/pkg/hello"
)

func TestHello(t *testing.T) {
    type test struct {
        input      string
        expected   string
    }

    tests := []test{
        {input: "world", expected: "hello world"},
        {input: "pullrequest", expected: "hello pullrequest"},
    }

    for _, tc := range tests {
        // perform setUp before each test here
        t.Run(tc.input, func(t *testing.T) {
            actual := hello.Hello(tc.input)
            if actual != tc.expected {
                t.Errorf("expected '%s', got '%s'", tc.expected, actual)
            }
            // perform tearDown after each test here
        })
    }
}

This is just a simple example above, and I am not actually using the setUp or tearDown, but you can see where you would insert such tasks. However, these table-driven tests can get as complicated as you need them to be. For some test cases, you may actually want to include a function as a parameter and call separate functions for the tests with a shared setUp and tearDown.

This is another example of how writing Go tests with the built-in tooling will actually teach you and improve your Go skills.

Testing within the same package

There are two main modularity options for how you want to test your Go package. You can create another package for your test code only, or you can add your test code in the same package that you are testing. Both ways will not actually include the code from your _test.go files in your binary as these files are ignored in compilation of your binary. The main difference here is whether or not you need or want to have access to internal functions and data of the package or not.

The way to switch between these two types of testing is by using package hello or package hello_test for your hello_test.go file.

For example, say you have a package that looks like this:

package hello

func world() string {
    return "world"
}

func HelloWorld() string {
    return "hello " + world()
}

If you wanted to be able to test the world function separately from the HelloWorld function, you could only do this if your test is in the same package hello. This is because world is an un-exported method, so you can’t access it outside of the package. So if you tried to test it from a package hello_test, you could get a compilation error indicating that there is no symbol world.

When possible, I recommend trying to use a separate testing package and just testing your publicly exported units. This tends to lead to a bit more modular code, and you get to test your package the same way that a consumer would use it. This can also help you to see how the API reads. However, I wouldn’t export extra internal functions just so that you can test the package in this way, and it is acceptable to test within the same package.

We will talk about mocking in a follow up to this post. Accepting interfaces to your public functions and using some mocks for the interfaces can often help with making your packages more testable from an external test package, which will also lead to more versatile packages that can be extended.

Illustration of the Golang Gopher by Renee French


About PullRequest

HackerOne PullRequest is a platform for code review, built for teams of all sizes. We have a network of expert engineers enhanced by AI, to help you ship secure code, faster.

Learn more about PullRequest

Tyler Mann headshot
by Tyler Mann

July 10, 2020