How I write tests in Go
How I write unit tests in Go⌗
One of my favorite features of Go is that unlike many popular languages, it comes with it’s own testing framework, the testing package. Let’s say we have this trivial function in a file called numbers.go
:
package numbers
func addNumbers(numbers ...int) int {
sum := 0
for _, number := range numbers {
sum += number
}
return sum
}
The idiomatic way to test this function is to create a second file called numbers_test.go
and test it using the aforementioned testing
package:
package numbers
import (
"testing"
)
func Test_addNumbers(t *testing.T) {
if addNumbers(3, 7) != 10 {
t.FailNow()
}
}
Then I can run go test
and see the output of the test:
ok github.com/example/package/numbers
Alternatively, if the test fails (i.e. if I expect the result to be 11 instead), I get an appropriate error report:
--- FAIL: Test_addNumbers (0.00s)
FAIL
FAIL github.com/example/package/numbers 0.255s
FAIL
This works pretty well, overall. When I need to find the tests for a given file, I can usually assume they’re in its corresponding _test.go
file. The testing
framework is built into the standard library, and does exactly what you’d expect with this code.
We have the technology⌗
This isn’t how I actually like to write Go unit tests, however. There are techniques and libraries I use to make unit testing in Go more effective to write and understand when things go (heh) awry.
The test command⌗
You can run go test
in a given directory and see the output of your test, but that leaves a lot of functionality on the table that can and will be useful. The full command I typically use is:
go test -cover -shuffle=on -race -vet=all -failfast <$RELEVANT_PACKAGE(S)>
Here’s what each of these flags do:
-cover
instructs the test runner to determine coverage for a given package.-shuffle=on
runs the tests in a random order, instead of in declared order.-race
activates the data race detector.-vet=all
“runs go vet on the package and its test source files to identify significant problems. If go vet finds any problems, go test reports those and does not run the test binary.”-failfast
stops test execution when a given unit test fails. It allows tests executed in parallel (more on that in a bit) to finish.
Table-driven tests⌗
I’m going to say something sort of controversial in the Go zeitgeist: I find table tests very rarely useful. For something like our addNumbers
function, I might actually do it, because the inputs and outputs are simple. Here’s what that might look like:
package numbers
import (
"testing"
)
type addNumbersTestCase struct {
inputs []int
expectedOutput int
}
func Test_addNumbers_table(t *testing.T) {
testCases := []addNumbersTestCase{
{
inputs: []int{4, 4},
expectedOutput: 8,
},
{
inputs: []int{10, 0},
expectedOutput: 10,
},
{
inputs: []int{-1, 1},
expectedOutput: 0,
},
}
for _, testCase := range testCases {
actual := addNumbers(testCase.inputs...)
if testCase.expectedOutput != actual {
t.FailNow()
}
}
}
That said, it’s pretty easy to end up in a situation where your table tests need to be quite complicated and gnarly to get full coverage.
Goland lets you generate tests for functions using a default template that attempts to adhere to the tradition of table-driven tests. Here’s what the generated test function looks like for a method in a side project. This method fetches a webhook object from the database, and accepts 3 total parameters — a context, a webhook ID, and an accountID:
package whatever
func TestQuerier_GetWebhook(t *testing.T) {
type fields struct {
tracer tracing.Tracer
logger logging.Logger
secretGenerator random.Generator
oauth2ClientTokenEncDec encryption.EncryptorDecryptor
generatedQuerier generated.Querier
timeFunc func() time.Time
config *dbconfig.Config
db *sql.DB
migrateOnce sync.Once
}
type args struct {
ctx context.Context
webhookID string
accountID string
}
tests := []struct {
name string
fields fields
args args
want *types.Webhook
wantErr assert.ErrorAssertionFunc
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q := &Querier{
tracer: tt.fields.tracer,
logger: tt.fields.logger,
secretGenerator: tt.fields.secretGenerator,
oauth2ClientTokenEncDec: tt.fields.oauth2ClientTokenEncDec,
generatedQuerier: tt.fields.generatedQuerier,
timeFunc: tt.fields.timeFunc,
config: tt.fields.config,
db: tt.fields.db,
migrateOnce: tt.fields.migrateOnce,
}
got, err := q.GetWebhook(tt.args.ctx, tt.args.webhookID, tt.args.accountID)
if !tt.wantErr(t, err, fmt.Sprintf("GetWebhook(%v, %v, %v)", tt.args.ctx, tt.args.webhookID, tt.args.accountID)) {
return
}
assert.Equalf(t, tt.want, got, "GetWebhook(%v, %v, %v)", tt.args.ctx, tt.args.webhookID, tt.args.accountID)
})
}
}
I think this is kind of a mess, and it doesn’t even include test cases yet. I’d rather have the querier built by an only-available-to-tests constructor that puts sensible defaults for all these values.
The point of this is not to dunk on Goland (I am a happily paying customer and have been for many years), it’s just to illustrate how even a non-trivial function can really blow up the readability of a table-driven test.
Parallel⌗
Go tests can be run in parallel, by declaring the test as parallel in the top level. This behavior is on by default, and can only be disabled by setting the -parallel=1
flag when invoking go test
(which can be useful when debugging flaky tests).
I’m of the opinion that tests should be parallel unless they absolutely cannot be, and even then, I’d rather fix the flaw behind why they can’t be run in parallel than force them to be run sequentially.
Running your tests in parallel generally allows you to suss out concurrency bugs earlier, even if you’re not using the race detector (which you should), and will eventually lead to you developing concurrency-safe habits when writing code in the first place. So generally, when I write a test, the very first line is the parallel declaration:
package numbers
import (
"testing"
)
func Test_addNumbers(t *testing.T) {
t.Parallel()
expected := 10
actual := addNumbers(3, 7)
if expected != actual {
t.FailNow()
}
}
Subtests⌗
Subtests are a feature of Go’s testing library that allow you to have smaller tests for specific circumstances. These allow you to get some of the benefits of a behavior-driven testing style, without having to invoke an external dependency. I usually write subtests even when there’s only one test case I care about, because it incurs no real overhead to do so, and allows you to easily test a new case in the event the tested function gets more complex.
When I write subtests, I usually name the top-level *testing.T
object a capitalized T
, and use the conventional lower-cased t
for subtests only. This way, the subtest logic always looks like an idiomatic Go test, and there’s no confusion of what variable is which or what shadows what. Calls to .Parallel()
will have to be made for both the big and little *T
variables.
Here’s a demonstration that adapts our earlier example for the addNumbers
function:
package numbers
import (
"testing"
)
func Test_addNumbers(T *testing.T) {
T.Parallel()
T.Run("standard", func(t *testing.T) {
t.Parallel()
expected := 10
actual := addNumbers(3, 7)
if expected != actual {
t.FailNow()
}
})
}
Now if I decide to add another test case, it’s simply a matter of copying the subtest block, changing the name, and then making the test match that description:
package numbers
import (
"testing"
)
func Test_addNumbers(T *testing.T) {
T.Parallel()
T.Run("standard", func(t *testing.T) {
t.Parallel()
expected := 10
actual := addNumbers(3, 7)
if expected != actual {
t.FailNow()
}
})
T.Run("with many numbers", func(t *testing.T) {
t.Parallel()
expected := 15
actual := addNumbers(1, 2, 3, 4, 5)
if expected != actual {
t.FailNow()
}
})
T.Run("with negative numbers", func(t *testing.T) {
t.Parallel()
expected := 3
actual := addNumbers(4, -1)
if expected != actual {
t.FailNow()
}
})
}
Additionally, if there are test fixtures or constants that are relevant to all subtests, they can live in the space between the big-T call to .Parallel()
and the first declared subtest.
Libraries⌗
While the standard library testing package is pretty great, there have been some great contributions by the community to the ecosystem that make testing in Go even better.
Fake Values⌗
Typically, when I need to interact with an object in a test, I want to have unpredictable values to the extent possible for that object, so that I’m discouraged from relying on those conventions when writing tests.
So for instance, if I need a *User
object for a test, I want to have no idea what it’s username might be, as opposed to a common static value that I might copy/paste from place to place, which would make many tests fail if it was inadvertently changed.
I like to use brianvoe/gofakeit for this. It comes with a ton of sensible functions with which to build fake instances of different types, and I’ve never had a problem with it. Here’s what that fake user builder might look like:
package whatever
import (
"github.com/example/project/internal/authorization"
"github.com/example/project/internal/pkg/pointer"
"github.com/example/project/pkg/types"
fake "github.com/brianvoe/gofakeit/v7"
)
func buildFakeTime() time.Time {
return fake.Date().Add(0).Truncate(time.Second).UTC()
}
func BuildFakeUser() *types.User {
fakeDate := buildFakeTime()
return &types.User{
ID: identifiers.New(),
FirstName: fake.FirstName(),
LastName: fake.LastName(),
EmailAddress: fake.Email(),
Username: fmt.Sprintf("%s_%d_%s", fake.Username(), fake.Uint8(), fake.Username()),
Birthday: pointer.To(buildFakeTime()),
AccountStatus: string(types.UnverifiedHouseholdStatus),
TwoFactorSecret: base32.StdEncoding.EncodeToString([]byte(fake.Password(false, true, true, false, false, 32))),
TwoFactorSecretVerifiedAt: &fakeDate,
ServiceRole: authorization.ServiceUserRole.String(),
CreatedAt: buildFakeTime(),
}
}
Testify⌗
I’m a huge fan of the (assert|require|mock)
libraries in testify. In our example test, using testify would look like:
package numbers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_addNumbers(T *testing.T) {
T.Parallel()
T.Run("standard", func(t *testing.T) {
t.Parallel()
expected := 10
actual := addNumbers(3, 7)
assert.Equal(t, expected, actual)
})
}
I think assert.Equal
is fairly obvious in its function for users who are new to a code base. testify
is probably the only library I can think of that I would wholeheartedly support being absorbed into the standard library.
You can use require
instead if you want the test to stop in the event of a failure. This is usually good for dependencies of a test, i.e. if I need to create a file and pass it to a function, I should probably require
that no errors happen when creating it:
package whatever
import (
"os"
"testing"
"github.com/stretchr/testify/require"
)
func TestSomeFunction(T *testing.T) {
T.Parallel()
T.Run("standard", func(t *testing.T) {
t.Parallel()
f, err := os.Create("some_path.txt")
require.NoError(t, err)
require.NotNil(t, f)
})
}
mock
is also useful for defining mock implementations of structures. Say I have the following interface:
package something
type UserRetriever interface {
GetUser(ctx context.Context, userID string) (*User, error)
}
Implementing a mock for this interface becomes a matter of:
package something
import (
"github.com/stretchr/testify/mock"
)
type mockUserRetriever struct {
mock.Mock
}
func (m *mockUserRetriever) GetUser(ctx context.Context, userID string) (*User, error) {
returnValues := m.Called(ctx, userID)
return returnValues.Get(0).(*User), returnValues.Error(1)
}
Using the above mock in a test looks like this:
package something
import (
"testing"
"github.com/stretchr/testify/mock"
)
func TestUserRetrieval(T *testing.T) {
T.Parallel()
T.Run("standard", func(t *testing.T) {
t.Parallel()
expected := &User{ID: "something"}
mur := &mockUserRetriever{}
mur.On("GetUser", mock.Anything, expected.ID).Return(expected, nil)
// pass our mock to whatever uses it and test it's functionality
mock.AssertExpectationsForObjects(t, mur)
})
}
I won’t lie, I’m not deeply impressed with the way that mock.Mock
determines which variables to return in what order, and when things go wrong (i.e. you copy and paste something in the wrong order or have the wrong indices on your return values) it’s VERY hard to suss out, but by and large, testify/mock
does an incredible job of what it needs to do.
You can avoid having to put mock.Anything
in an expectation call by writing a type matcher. For context.Context
, it would look like this:
mock.MatchedBy(func(context.Context) bool { return true })
There’s also testify/suite
, which I have used, but wouldn’t recommend. It’s useful when you have a ton of common prerequisites for tests that need to be spun up ahead of time, but I still think it makes more sense to just make those prerequisites the product of a common function, instead of writing non-standard test code.
Testcontainers⌗
This is a neat tool I’ve only recently started using, but to great effect. It’s very common to have to write code that interfaces with a database, or a k/v store, or an in-memory cache, but before testcontainers, you either had to write a suite of integration tests that used that code to verify it did what you wanted, or otherwise fly blind in production. With testcontainers, I can spin up ephemeral containers that run a given piece of software and have my code interact with that to verify functionality.
In practice, it’s not perfect. These tests generally run great on my machine, and in Github Actions, but you must remember that you’re asking your computer to make a fake computer within itself, spin up a piece of software on that fake computer, and handle all the networking shenanigans that go along with interfacing with it, so there’s plenty of opportunity for mishaps and errors to occur.
Sometimes a testcontainer invocation will just fail, and the reason behind it can be summarized up as: computers are hard. Since they can be a big more error prone than standard tests, I don’t typically write subtests for these. Instead, I tend to write one big function that uses a single instance of the container to test a bunch of related functions.
Here’s an example of the test for that aforementioned webhook retrieval function:
package whatever
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/example/project/pkg/types"
"github.com/example/project/pkg/types/converters"
"github.com/example/project/pkg/types/fakes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/matryer/try.v1"
)
func buildDatabaseClientForTest(t *testing.T, ctx context.Context) (*Querier, *postgres.PostgresContainer) {
t.Helper()
dbUsername := fmt.Sprintf("%d", hashStringToNumber(t.Name()))
testcontainers.Logger = log.New(io.Discard, "", log.LstdFlags)
var container *postgres.PostgresContainer
err := try.Do(func(attempt int) (bool, error) {
var containerErr error
container, containerErr = postgres.RunContainer(
ctx,
testcontainers.WithImage(defaultImage),
postgres.WithDatabase(splitReverseConcat(dbUsername)),
postgres.WithUsername(dbUsername),
postgres.WithPassword(reverseString(dbUsername)),
testcontainers.WithWaitStrategyAndDeadline(2*time.Minute, wait.ForLog("database system is ready to accept connections").WithOccurrence(2)),
)
return attempt < 5, containerErr
})
require.NoError(t, err)
require.NotNil(t, container)
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
dbc, err := ProvideDatabaseClient(ctx, logging.NewNoopLogger(), tracing.NewNoopTracerProvider(), &config.Config{ConnectionDetails: connStr, RunMigrations: true, OAuth2TokenEncryptionKey: "blahblahblahblahblahblahblahblah"})
require.NoError(t, err)
require.NotNil(t, dbc)
return dbc, container
}
func createWebhookForTest(t *testing.T, ctx context.Context, exampleWebhook *types.Webhook, dbc *Querier) *types.Webhook {
t.Helper()
// create
if exampleWebhook == nil {
exampleWebhook = fakes.BuildFakeWebhook()
}
dbInput := converters.ConvertWebhookToWebhookDatabaseCreationInput(exampleWebhook)
created, err := dbc.CreateWebhook(ctx, dbInput)
assert.NoError(t, err)
require.NotNil(t, created)
assert.Equal(t, exampleWebhook, created)
webhook, err := dbc.GetWebhook(ctx, created.ID, created.BelongsToAccount)
assert.NoError(t, err)
assert.Equal(t, webhook, exampleWebhook)
return created
}
func TestQuerier_Integration_Webhooks(t *testing.T) {
ctx := context.Background()
dbc, container := buildDatabaseClientForTest(t, ctx)
databaseURI, err := container.ConnectionString(ctx)
require.NoError(t, err)
require.NotEmpty(t, databaseURI)
defer func(t *testing.T) {
t.Helper()
assert.NoError(t, container.Terminate(ctx))
}(t)
user := createUserForTest(t, ctx, nil, dbc)
accountID, err := dbc.GetDefaultAccountIDForUser(ctx, user.ID)
require.NoError(t, err)
require.NotEmpty(t, accountID)
exampleWebhook := fakes.BuildFakeWebhook()
exampleWebhook.BelongsToAccount = accountID
createdWebhooks := []*types.Webhook{}
// create
createdWebhooks = append(createdWebhooks, createWebhookForTest(t, ctx, exampleWebhook, dbc))
// other tests would go here
// delete
for _, webhook := range createdWebhooks {
assert.NoError(t, dbc.ArchiveWebhook(ctx, webhook.ID, accountID))
var exists bool
exists, err = dbc.WebhookExists(ctx, webhook.ID, accountID)
assert.NoError(t, err)
assert.False(t, exists)
var y *types.Webhook
y, err = dbc.GetWebhook(ctx, webhook.ID, accountID)
assert.Nil(t, y)
assert.Error(t, err)
assert.ErrorIs(t, err, sql.ErrNoRows)
}
}
(I acknowledge that this code is missing some function definitions, but it’s meant for you to get an idea, not copy/paste)
Failure Cases⌗
Some functions in the Go standard library return errors that you might surface to higher-level call sites, but in order to test that those errors are caught and returned, Here are some ways I manage to achieve failures for very specific circumstances in Go.
JSON Encoding⌗
It’s a pretty common operation in Go code to take an instance of a given struct and render it as JSON. This function is capable of returning an error in the event the source struct is unrepresentable, but it’s actually pretty hard to make a struct unrepresentable in Go. You basically have to do it on purpose.
The way I achieve this is by using json.Number
, a string alias meant to represent numbers, and giving it a non-numerical value. Here’s what that looks like:
type BreakableStruct struct {
Thing json.Number
}
func TestRenderToJSON(T *testing.T) {
T.Parallel()
T.Run("with invalid structure", func(t *testing.T) {
t.Parallel()
x := &BreakableStruct{Thing: "stuff"}
actual, err := json.Marshal(x)
assert.Nil(t, actual)
assert.Error(t, err)
})
}
URL Parsing⌗
Parsing a URL in Go returns an error, but trying to force this error to occur was very confusing to me. I had to dive into the actual test cases in the standard library for net/url
. Here are some inputs you might think return an error from url.Parse
, but actually don’t:
- an empty string
- a URL with double dots in the path (i.e.
https://google.com/../../var/data
) - just a scheme (i.e.
https://
) - a URL with emojis in it (i.e.
https://😘.🔥
) - a URL with an invalid scheme (i.e.
farts://
)
All of these actually don’t return an error. The only way I was able to force it to occur by using:
fmt.Sprintf(`%s://`, string(byte(127)))
For whatever reason, this causes url.Parse
to fail.
Conclusion⌗
In many other languages, you have to not only evaluate testing libraries, but also write your tests in a style that complies with that library’s expectations. Gophers are blessed to have a thoroughly adequate solution out-of-the-box, and even further blessed to have an active ecosystem where folks are making in-depth testing a walk in the park.