Blog | GitHub | CV | GPG key

Testing in Go

Published:
Tags: Go, Testing

This is a collection of testing techniques and patterns that I have learned throughout my career of being a Go programmer.

testing package basics

The Go standard library comes with the testing package which provides a solid base for writing tests.

Each test should be a separate function. A test function must accept a single argument of type *testing.T.

A test for a functoin isEven could look like this:

func TestIsEven(t *testing.T) {
    if !isEven(2) {
        t.Fatal("2 is even")
    }
    if isEven(1) {
        t.Fatal("1 is odd")
    }
}

Run your test by using the go test command, for example

# Test this directory
$ go test .

# Test the whole project recursively.
$ go test a-package.com/path/...

Failing and messages

Each test accepts one argument, a T instance. T provides methods that allow to print information and control the flow of a test.

Use t.Log and t.Logf methods to write a message.

Use t.Error and t.Errorf methods to write a message and mark the test as failed.

Use t.Fatal and t.Fatalf methods to write a message, mark the test as failed and instantly terminate that test execution.

Write good error messages

A good error message is concise and short. Sprinkle each result with a bit of context.

if isEven(1) {
    t.Fatal("1 is an odd number")
}
if want, got := 42, compute(); want != got {
    t.Fatalf("want %d, got %d", want, got)
}

By declaring got and want I am sure that what is tested for is what I print. If the compute function was changed and in the new implementation want should be 33 I cannot make the mistake of not updating the error message. Both got and want are scoped to the if statement only.

When writing a table test, declaring an expected value might not be necessary. The expected value can be easily found in the test declaration.

Skipping a test

Some tests should run only under special circumstances. For example, you want to run a test only if a database is available. t.Skip and t.Skipf methods allow to cancel (skip) the currently running test without failing it.

func TestDatabaseIntegration(t *testing.T) {
    db, err := connectToDatabase("test-database")
    if err != nil {
        t.Skipf("cannot connect to database: %s", err)
    }
    defer db.Close()

    // ...
}

Test helpers

Often times many tests require similar dependencies, for example running a service or preparing a state. Instead of repeating the preparation code extract each functionality to a separate function.

Test helpers: Setting up dependencies

If you are testing code that depends on an external database, this is how the beginning of a test function might look like:

func TestDatabaseIntegration(t *testing.T) {
    db, err := connectToDatabase("test-database")
    if err != nil {
        t.Skipf("cannot connect to database: %s", err)
    }
    defer db.Close()
    if err := db.Ping(); err != nil {
        t.Fatalf("cannot ping database: %s", err)
    }

    for i, migration := range databaseMigrations {
        if err := db.ApplyMigration(migration); err != nil {
            t.Fatalf("cannot apply %d migration: %s", i, err)
        }
    }

    mycollection := NewCollection(db)

    // The actual test starts below.
    // ...
}

A solution to code repetition can be to create a function that will encapsulate certain functionality. The whole setup and teardown process for a test can be extracted.

func TestDatabaseIntegration(t *testing.T) {
    mycollection, cleanup := ensureMyCollection(t, "test-database")
    defer cleanup()

    // The actual test starts below.
    // ...
}

func ensureMyCollection(t testing.TB), dbName string (MyCollection, func(){} {
    t.Helper()

    db, err := connectToDatabase(dbName)
    if err != nil {
        t.Skipf("cannot connect to database: %s", err)
    }

    if err := db.Ping(); err != nil {
        db.Close()
        t.Fatalf("cannot ping database: %s", err)
    }

    for i, migration := range databaseMigrations {
        if err := db.ApplyMigration(migration); err != nil {
            db.Close()
            t.Fatalf("cannot apply %d migration: %s", i, err)
        }
    }
    collection := NewCollection(db)
    cleanup := func() {
        db.Close()
    }
    return collection, cleanup
}

With the above solution, ensureMyCollection can be used by many test functions to ensure that a collection using a database as a backend is available. A helper function hides the for the test logic irrelevant part of setting up an environment and ensuring all components are provided.

A helper function accepts testing.TB interface instead of t *testing.T. That makes it useful for both test and benchmark functions.

A helper function does not return an error. Instead, it directly terminates the test by calling t.Fatal.

At the beginning of the helper function the t.Helper() method is called. This marks this function and when it fails the stack information and error will be more helpful.

ensureMyCollection returns a cleanup function. This is a convenient way of cleaning up all created resources. The user of this helper must call it once the returned resource is not needed anymore. The cleanup function should not return anything nor fail the test.

Blackbox package testing

Test files that declare a package with the suffix “_test” will be compiled as a separate package, and then linked and run with the main test binary. – golang.org

Test files for your package are located in the same directory as the code they test. Your tests can belong to the same package as the rest of the code. It is also possible to enforce a black-box test for your package. Your test files can be in the same directory as your package code and use a different package name.

package xxx_test

Using a different test package name enforces that only the public interface of the tested package is accessible. This is for example how strings and bytes packages are tested.

Third party test helper packages

I do not use any additional packages for testing. I am of an opinion that assert functions are not as helpful as one may think. Introducing an external package requires learning a new API.

Someone else wrote a great summary on the topic.

Complex comparisons can usually be done using reflect.DeepEqual function.

reflect.DeepEqual

Those values that cannot be compared with ==, most of the time can be compared with reflect.DeepEqual.

Table tests

When testing a functionality a single input is often not enough to ensure correctness. Repeating the same operation for many cases can be implemented using table tests.

Use a map with strings as keys to provide a description of each test case.

func TestDiv(t *testing.T) {
    cases := map[string]struct{
        A int
        B int
        WantRes int
        WantErr error
    }{
        "two positive numbers": {
            A: 4,
            B: 2,
            WantRes: 2,
        },
        "divide by zero": {
            A: 4,
            B: 0,
            WantErr: errors.ErrZeroDivision,
        },
    }

    for testName, tc := range cases {
        t.Run(testName, func(t *testing.T) {
            res, err := Div(tc.A, tc.B)
            if !errors.Is(err, tc.WantErr) {
                t.Fatalf("unexpected error: %q", err)
            }
            if res != tc.WantRes {
                t.Fatalf("unlexpected result: %d", res)
            }
        })
    }
}

When declaring a test case, always use field names. This increases the readability and you have to provide only non zero values.

cases := map[string]struct{
    DB *Database
    Req *Request
    WantRes int
    WantErr error
}{
    // BAD
    {nil, myrequest, 32, nil},

    // GOOD
    {
        Req: myrequest,
        WantRes: 32,
    },
}

Mocking

Write your code to accept interfaces. Using interfaces allows you to test a single layer of a functionality at a time.

For example, if you are writing an application that is storing data in an SQL database, instead of accessing the database directly through a *sql.DB instance use a wrapper. Using a data access abstraction allows for mocking.

When writing a mock you do not have to implement all methods. For the compiler it is enough to include the interface in the mock declaration. Implement only methods that you intend to call.

type Collection interface {
    One(id uint64) (*Entity, error)
    List() ([]*Entity, error)
    Add(Entity) error
    Delete(id uint64) error
}

type CollectionMock struct {
    Collection
    Err error
}

func (c *CollectionMock) Add(Entity) error {
    return c.Err
}

CollectionMock implements the Collection interface, but using any other method than Add will panic. See the full example.

Your code should provide a mock

When writing a package that is used by others provide test implementations of your interfaces.

This approach is taken by the standard library. For example, httptest.ResponseRecorder allows to test your HTTP handler without using a real http.ResponseWriter.

Test flags

You can add your own flags to the go test command in order to customize your tests. Use the flag package and declare your flags globally.

var dbFl = flag.String("db", "", "Use given database DSN.")

Environment variables

Instead of flag you can control your tests using environment variables. If you follow the 12 factor app principles then your application is already utilizing environment variables for the configuration.

var dbDSN = os.Getenv("DATABASE_DSN")

Fixtures

If your test requires fixtures /testdata is the directory you should consider keeping them in.

The go tool will ignore a directory named “testdata”, making it available to hold ancillary data needed by the tests. – golang.org

When running tests each test function is executed with its working directory set to the source directory of the tested package. That means that when accessing files in /testdata you can safely use relative path

fd, err := os.Open(filepath.Join("testdata", "some-fixture.json"))

Golden files

Golden files are a great way to validate and keep track of a test output. Together with a version control system they are much easier to maintain than strings hard coded in functions.

var goldFl = flag.Bool("gold", false, "Write result to golden files instead of comparing with them.")


func TestExample(t *testing.T) {
    // Test logic.
    result := ...

    if *goldFl {
        writeGoldenFile(t, result)
    }

    compareWithGoldenFile(t, result)
}

This technique comes in very helpful combined with table tests.

Integration tests

For a well written application integration testing should not require more work than usual testing. For each external resource provide a single function to setup and teardown the resource.

Build constraints

You can use a build constraint to conditionally build code in a file.

$ head -n 1 app_intergration_test.go
// +build integration

To run tests including those tagged as integration use -tag flag.

$ go test -tag integration .

Setup/teardown

When using the testing package, it is possible to overwrite the test main function.

Using a custom test main function allows to execute code before and after executing all discovered tests. This can be running an external dependency like a database instance or building a binary that tested functionality might depend on.

func TestMain(m *testing.M) {
    // Setup code.

    // defer Teardown code.

    os.Exit(m.Run())
}

-race

Run tests with -race flag to enable data race detection.

This functionality is not available on musl based systems.

Testing FAQ

Check the FAQ at golang.org.