1 May 2020

Tips to Take Table Tests to the Top

Testify.

I’m a huge fan of Go’s table-driven tests. They’re really well-suited for behavioural testing, which we discussed before, and they make writing tests quite easy.

As with the previous post in this series, this was written to prepare for a learning session on tests at work; the content here is a collection of suggestions and observations based on how our team was writing tests. Maybe you’ll find it useful too; maybe you won’t.

I make no apologies for this post’s title.

Tools exist to generate the skeleton of a table-driven test automatically.

If you’re using Visual Studio Code for Go development (and according to the most recent Go Developer Survey, chances are good that you do), the Go extension adds a convenient Generate Unit Tests for Function option to the Right-Click menu. This functionality is powered by the gotests tool, which can also be integrated into your alternative editor of choice.

Screenshot of the VS Code right-click context menu, with the Generate Unit Tests For Function option highlighted.

Selecting this results in a test file being created (if necessary) and an empty test table generated for the function.

func TestIsUserActive(t *testing.T) {
	type args struct {
		u User
	}
	tests := []struct {
		name string
		args args
		want bool
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := IsUserActive(tt.args.u); got != tt.want {
				t.Errorf("IsUserActive() = %v, want %v", got, tt.want)
			}
		})
	}
}

Note the use of an argument struct here - we’ll revisit this later on.

This isn’t limited to just functions, too - methods on structs with fields are handled nicely as well1:

type User struct {
	Name         string
	EmailAddress string
	Birthday     time.Time // TODO: display on profile.
}

// CalculateAge calculates the user's age.
func (u User) CalculateAge() int {
	// Implementation here.
}

func TestUser_CalculateAge(t *testing.T) {
	type fields struct {
		Name         string
		EmailAddress string
		Birthday     time.Time
	}
	tests := []struct {
		name   string
		fields fields
		want   int
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			u := User{
				Name:         tt.fields.Name,
				EmailAddress: tt.fields.EmailAddress,
				Birthday:     tt.fields.Birthday,
			}
			if got := u.CalculateAge(); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("User.CalculateAge() = %v, want %v", got, tt.want)
			}
		})
	}
}

We now have a fields struct which lets us easily initialise the struct to be tested. If we had arguments to the method, there’d be an args struct as well, just as we saw earlier.

This gives us a good launching point to start writing tests quickly. I use this feature all the time to get the outline up, then make changes - we use stretchr/testify‘s assert package to do assertions, so I’d rip out the if statement the tool generates and replace it with the appropriate calls. You could also prune out any fields which aren’t strictly necessary - in our example here, it seems unlikely that CalculateAge is going to use Name or EmailAddress, so you could drop them.

I’d also then parallelise the test, which brings us to our next point.

Run all your tests in parallel, if you can.

It’s a good idea in general to run tests in parallel, and by default go test will use the number of cores available to do so. But tests themselves won’t magically run in parallel - you need to add t.Parallel() to them.

This is pretty easy for a regular test - just throw it at the start of the test func - but for a table-driven one there’s a little bit more that you have to do:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func TestUser_CalculateAge(t *testing.T) {
	t.Parallel()

	type fields struct {
		Name         string
		EmailAddress string
		Birthday     time.Time
	}
	tests := []struct {
		name   string
		fields fields
		want   int
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			u := User{
				Name:         tt.fields.Name,
				EmailAddress: tt.fields.EmailAddress,
				Birthday:     tt.fields.Birthday,
			}
			if got := u.CalculateAge(); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("User.CalculateAge() = %v, want %v", got, tt.want)
			}
		})
	}
}

We have to add t.Parallel() to the overall test function itself (line 2) as well as each sub-test (line 17). More importantly, though, we also need to alias tt to itself on line 19 - otherwise not all of the tests will actually get run. This section of the Go wiki explains more.

It’s also worth noting that not all tests can be run in parallel; if your test is for code that needs to rely on shared state and which modifies that state (an external database or file, some global variable, etc.), it’s likely not going to work in parallel. This is a code smell, though, and running tests in parallel can help you find places where this is the case when it wasn’t intended and hopefully fix the issue.

Consider using a map instead of a slice for the table.

A team mate actually suggested doing this some time back, though it didn’t quite stick. Dave Cheney’s post about table-driven tests does suggest using maps, so it’s not entirely unheard of, but it does seem quite uncommon in the Go ecosystem as a whole; most tests use slices instead.

The benefits of using a map instead of a slice are that you can drop the name field from the test struct (since the map key can be used for it instead) and that tests are no longer run in a fixed order since map iteration order is undefined. This is a good thing since the order in which your tests run shouldn’t affect whether or not they pass; it’s a code smell if it does.

It’s really hard to shake the habit of using a slice, though. It being the default means it’ll likely live on, which is fine too.

Name your tests using a description of the expected behaviour.

I’ve seen tests with names like correct behavior, error, and success. These names don’t really say anything about what exactly is being tested - they’re too generic and require you to look at the exact arguments and expectations to figure it out.

Naming tests in a more descriptive way - describing what the inputs/preconditions are, and what the expected result/behaviour is - lets you read the name of the test and understand everything. The pitfall here is that this is similar to a comment: if it’s not kept up to date, it’s useless.

One way to think about this is returns x given y or does x when y, where x and y are also high-level descriptions and not the literal values.

returns an error when the user is not found is better than user not found. sends an email on successful registration is better than successful.

Avoid having test set-up outside of the test itself.

In my opinion, one of the biggest benefits of a table-driven test is that you can see everything related to a particular test instance in a single place. This is lost if you end up having set-up outside the test itself, though.

One of the biggest reasons I’d end up doing or seeing this happen early on was that an argument or field value needed to be an interface or pointer, and you couldn’t just initialise those in-line:

// Assuming a Nickname *string field on our user...

type fields struct {
	Nickname     *string
}

tests := []struct {
	name   string
	fields fields
	want   bool
}{
	{
		"returns true if a nickname is set",
		fields{
			Nickname: &"Ayulin", // This isn't valid!
		},
		true,
	},
}

So we’d end up defining the value before the test table instead.

nickname := "Ayulin"

tests := []struct {
	name   string
	fields fields
	want   bool
}{
	{
		"returns true if the user has a nickname set",
		fields{
			Nickname: &nickname,
		},
		true,
	},
}

You’d see similar with mocked interfaces, too - the mock is declared above the start of the table and referenced inside.

This now requires us to bounce back and forth between the test instance in the table and the code above the table, though, and that “foreword” section often ends up pretty long and cluttered. You can get around this and do everything in-line by using anonymous functions instead:

tests := []struct {
	name   string
	fields fields
	want   bool
}{
	{
		"returns true if the user has a nickname set",
		fields{
			Nickname: func() *string { s := "Ayulin"; return &s },
		},
		true,
	},
}

Longer, more involved initialisations are a lot more readable if they’re split onto multiple lines.

This technique keeps everything in one place - the test is entirely self-contained and doesn’t require referencing things defined elsewhere. The downside here is that the test instance itself ends up being longer (and some would say more cluttered), especially if there’re mock definitions being set up in-line. I think this is still preferable to having all of the clutter in one non-segregated place; you have proximity here, which is better than everything being put in one big, unrelated chunk at the start of the test function.

The next topic is pretty similar.

Avoid defining helper functions for tests, unless you’re in a test package.

Helper functions can be useful to DRY up some of the stuff you’d do for test set-up, but they also end up polluting the namespace of your package. Since they’re private this isn’t an issue for your callers, but it’s still not great; I’ve seen examples where core business logic relied on a helper function defined in a test file.

The better solution is to define the helper as an anonymous function as part of your test function itself. This is an exception to the previous point about not putting stuff in the preamble of your test function, before the table.

That is, instead of:

func initMockUserDatabase(<some parameters>) user.Database {
	// <implementation to prepare and return a mock>
}

Put this inside the test function instead:

func TestGateway_GetUser(t *testing.T) {
	initMockUserDatabase := func(<some parameters>) user.Database {
		// <implementation to prepare and return a mock>
	}

	// Test tables below.
}

This does somewhat go against what we just said, about doing everything within the test instance itself, though for cases where a helper does make things clearer this strategy can be useful.

Of course, if you’re operating within a separate _test package, this isn’t really necessary.

Use structs to handle both arguments and expectations.

We saw that gotests creates an args struct for method/function arguments automatically, though it doesn’t create one for expected return values. I think doing so is a little clearer and better than using the want prefix that gets used quite frequently.

So turn this:

tests := []struct {
	name string
	args
	want User
	wantErr bool
}{}

Into:

type want struct {
	user User
	err bool
}
tests := []struct {
	name string
	args
	want
}{}

With just a single return value this isn’t too useful, though if you happen to have multiple or want to be more descriptive about what the return value is, this helps there. I’m also not a fan in general of the want prefix. YMMV.

Don’t check error messages unless they’re part of your contract (and avoid having that too!)

You might have noticed that, when generating a test table for a function or method that returns an error, gotests will use a Boolean wantErr field in the test struct and not an error itself or even a string for the expected error message. This is good. Don’t check that a specific error message is being returned.

If you’re checking that the error returned has a specific error message, that’s an indication that the test is overfitting to the implementation - unless you actually need to return specific errors as part of your code’s contract, you can do without these checks. It’s also better to not have that even be part of your contract to begin with - code shouldn’t be introspecting error messages at all.

If you really need to be able to have code take different actions depending on the sort of error that was encountered, keep using a Boolean wantErr and instead introduce an “error checker” func that’ll check if the error implements a specific interface:

type retryable interface {
	IsRetryable() bool
}

func IsRetryable(err error) bool {
	re, ok := err.(retryable)
	return ok && re.IsRetryable()
}

// ...

type want struct {
	err bool
	isCorrectErr func(error) bool
}

tests := []struct{
	name string
	args
	want
}{
	{
		"returns a retryable error if blah",
		args{
			// something or another
		},
		want{
			err: true,
			isCorrectErr: IsRetryable,
		},
	},
}

This type of test still verifies that the correct behaviour is met - the code is returning the correct sort of error under certain circumstances - while not coupling it to an error message that can change frequently. Dave Cheney goes into the idea of using interfaces for error behaviours in more detail here.


  1. If you were actually implementing this method, you’d have to have some sort of way to stub out the clock in order to actually test this; the signature would probably look different as a result. This is just an example, though (and honestly, I forgot about that complication when initially writing it), so we’re not doing that here. ↩︎