Go testing patterns

Márk Sági-Kazár


2021-10-11 @ Golang Szeged

About me

  • Eng. Tech Lead / SRE @ Cisco
  • I write Go for a living (for 5+ years now)
  • Love OSS
  • Obsessed with DX

https://sagikazarmark.hu

https://about.sagikazarmark.hu

@sagikazarmark

Basics

Builtin test framework

$ go test
ok      pkg/pets    1.303s
ok      pkg/store   0.037s
ok      pkg/store/storeadapter  0.025s
ok      pkg/store/payment   0.111s

Anatomy of a test

files ending with _test.go

functions starting with Test[A-Z]

func(t *testing.T) signature

package pets

import "testing"

func TestDogBarks(t *testing.T) {
    dog := NewAnimal(Dog)

    if dog.Barks() == false {
        // a dog should bark
    }
}

Test output/result

  • t.Log()
  • t.Error()
  • t.Fatal()
  • t.Skip()

Parallel test running

Tests are executed concurrently by default. Packages are tested in parallel by default.

import "testing"

func TestSomething(t *testing.T) {
    t.Parallel()
}

Running tests

# Run tests
$ go test

# Run tests for specific packages
$ go test ./pkg/...

# Run tests in verbose mode to see every output
$ go test -v

# Run a specific test
$ go test -run ^TestIntegration$

# Run tests with the race detector
$ go test -race

Test types

  • Test
  • Benchmark
  • Example

Writing tests

func TestDogBarks(t *testing.T) {
    dog := NewAnimal(Dog)

    if dog.Barks() == false {
        t.Error("a dog should be able to bark")
    }
}
func TestGetDog(t *testing.T) {
    dog, err := getDog(1)
    if err != nil { // oneliner?
        t.Fatal(err)
    }

    if got, want := dog.ID, 1; got != want { // oneliner?
        t.Errorf("id mismatch\nactual:   %+v\nexpected: %+v", got, want)
    }
}

Helpers

func noError(t *testing.T, err error) {
    t.Helper()

    if err != nil {
        t.Fatal(err)
    }
}
func TestGetDog(t *testing.T) {
    dog, err := getDog(1)
    noError(t, err)

    // ...
}

Assertions

Testify: https://github.com/stretchr/testify

import "github.com/stretchr/testify/assert"
import "github.com/stretchr/testify/require"

func TestGetDog(t *testing.T) {
    dog, err := getDog(1)
    require.NoError(t, err)

    assert.Equal(t, 1, dog.ID)
}

Dependencies

type DogService struct {
    Repository DogRepository
}

type Dog struct {
    ID string
    // ...
}

type DogRepository interface {
    GetDog(id string) (Dog, err)
}

Test doubles: stubs

type inmemDogRepository struct{
    dogs map[string]Dog
}

func (r inmemDogRepository) GetDog(id string) (Dog, error) {
    dog, ok := r.dogs[id]
    if !ok {
        return Dog{}, ErrDogNotFound
    }

    return dog, nil
}

Test doubles: mocks

Testify: github.com/stretchr/testify/mock

func TestFoo(t *testing.T) {
    foo := new(Foo)

    foo.On("DoSomething", 123).Return(true, nil)

    bar(foo)

    foo.AssertExpectations(t)
}

Test doubles: generated mocks

Generator: https://github.com/vektra/mockery

type Stringer struct {
    mock.Mock
}

func (m *Stringer) String() string {
    ret := m.Called()

    var r0 string
    if rf, ok := ret.Get(0).(func() string); ok {
        r0 = rf()
    } else {
        r0 = ret.Get(0).(string)
    }

    return r0
}

Test organization

Test per feature

package pets

import "testing"

func TestDogBarks(t *testing.T) {
    dog := NewAnimal(Dog)

    if dog.Barks() == false {
        // a dog should bark
    }
}

func TestDogBites(t *testing.T) {
    // ...
}

Subtests

package pets

import "testing"

func TestDog(t *testing.T) {
    dog := NewAnimal(Dog)

    t.Run("barks", func(t *testing.T) {
        // ...
    })

    t.Run("bites", func(t *testing.T) {
        // ...
    })
}
$ go test -run TestDog/barks

Test suites

Testify: github.com/stretchr/testify/suite

func TestDogTestSuite(t *testing.T) {
    suite.Run(t, new(DogTestSuite))
}

type DogTestSuite struct {
    suite.Suite
}

func (s *DogTestSuite) SetupTest() {}

func (s *DogTestSuite) AfterTest(suiteName, testName string) {}

func (s *DogTestSuite) Test_Barks() {
    // ...
}

Table driven tests

func TestSplit(t *testing.T) {
    tests := struct{
        name  string
        input string
        sep   string
        want  []string
    } {
        {
            name:  "empty",
            input: "",
            sep:   ",",
            want:   []string{""},
        },
    }
}

Table driven tests

func TestSplit(t *testing.T) {
    // ...

    for _, tc := range tests {
        tc := tc

        t.Run(tc.name, func(t *testing.T) {
            assert.Equal(t, Split(tc.input, tc.sep), tc.want)
        })
    }
}

Integration tests

Build tags

//go:build integration

package foo

func TestFoo(t *testing.T) {
    // ...
}
$ go test -tags integration

https://peter.bourgon.org/blog/2021/04/02/dont-use-build-tags-for-integration-tests.html

Short tests

package foo

func TestFoo(t *testing.T) {
    if t.Short() {
        t.Skip("skipping test in short mode")
    }

    // ...
}
$ go test -short

Environment variables

func TestFoo(t *testing.T) {
    fooAddr := os.Getenv("FOO_ADDR")
    if fooAddr == "" {
        t.Skip("set FOO_ADDR to run this test")
    }

    f, err := foo.Connect(fooAddr)
    // ...
}
$ FOO_ADDR=127.0.0.1:8080 go test

Test name

func TestIntegration(t *testing.T) {
    if m := flag.Lookup("test.run").Value.String(); m == "" || !regexp.MustCompile(m).MatchString(t.Name()) {
        t.Skip("skipping as execution was not requested explicitly using go test -run")
    }

    t.Run("foo", testFoo)
    // ...
}

func testFoo(t *testing.T) {
    // ...
}
$ go test -run ^TestIntegration$

“Tips”

Take them with a grain of salt.

CI: Race detector

$ go test -race

CI: Code coverage

https://github.com/gotestyourself/gotestsum

Avoid “test frameworks”

What’s new

Go 1.16: testing/fstest

  • Filesystem tests
  • MapFS

Go 1.17: TB.Setenv

func TestFoo(t *testing.T) {
    t.Setenv("KEY", "VALUE")
}

Must not be used in parallel tests (ie. with t.Parallel).

Go 1.18: Fuzzing

func FuzzParseQuery(f *testing.F) {
    f.Add("x=1&y=2")
    f.Fuzz(func(t *testing.T, queryStr string) {
        // ...
    })
}

https://go.dev/blog/fuzz-beta

The End

Questions?

https://sagikazarmark.hu

@sagikazarmark