Table-driven tests are a cornerstone of good unit testing in any language, Go included. They allow us to group related test cases, making it simple to cover numerous inputs, outputs, and edge cases in a concise and readable way. When you have functionality that needs to be exercised in a variety of ways, a table test is often the right tool for the job.
The Standard Approach
A common and idiomatic way to write table tests in Go is to declare the set of test cases using a slice of anonymous structs. Each element in the slice represents a single case, defining the inputs and the expected outcome:
func TestLookup(t *testing.T) {
cases := []struct {
name string
lookup string
expected string
}{
{
name: "finds the first entry",
lookup: "first",
expected: "found",
},
{
name: "copes with a missing entry",
lookup: "not-there",
expected: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Test logic goes here...
})
}
}
This is easy to read, and adding a new test case is as simple as adding another struct to the slice. However, this pattern has a couple of subtle weaknesses that can become liabilities.
The Trouble with Slices
The first issue is the name field. It exists purely to satisfy the t.Run method, which requires a name for each sub-test - but nothing is enforcing that our tests have unique names.
It’s thus common to see multiple tests with the same name. While Go’s test runner will still execute them, having multiple tests with the same name makes it difficult to identify which specific case has failed, forcing you to hunt through the test logs.
It’s super easy to copy-paste a test case and forget to change the name, leading to confusing test output.
A Better Way: Use a Map
We can eliminate these problems with a simple change: using a map instead of a slice to hold our test cases.
By declaring a map where the key is the test name, we get several benefits. Let’s refactor our previous example:
func TestLookup(t *testing.T) {
cases := map[string]struct {
lookup string
expected string
}{
"finds the first entry": {
lookup: "first",
expected: "found",
},
"copes with a missing entry": {
lookup: "not-there",
expected: "",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
// Test logic goes here...
})
}
}
This small change brings three distinct advantages.
First, the test names are now front and centre. They are the keys of the map, making the list of test cases much easier to scan and understand. As a bonus, the name field inside the struct is gone, reducing noise.
Second, the compiler now guarantees the uniqueness of our test names. You cannot have duplicate keys in a map, so you are protected from accidentally creating two test cases with the same description. If you copy-paste a test case, the compiler reminds you to give it a distint name.
Finally — and this is a subtle benefit — the iteration order of a map in Go is not guaranteed. Sometimes, running test cases in a different order can flush out hidden problems in your tests, such as temporal coupling where one test case inadvertently depends on state left behind by a previous one. Tests should always be independent, and non-deterministic execution nudges you in that direction.
It is a small refinement, but switching from a slice to a map for your table-driven tests can make them clearer, easier to maintain, and perhaps more robust as well.
Initial draft written by AI based on a detailed outline written by Bevan. Final editing by Bevan.




Comments
blog comments powered by Disqus