Table-driven tests in Go
1. Overview
When a function has many branches, you’ll want to test many different input/output combinations. If you write each case as a separate test function, test code will repeat itself endlessly and become hard to maintain.
Table-driven tests are the most common testing pattern in the Go community. You describe test cases as a “table” of data, then use a single loop to run them all.
After reading this article, you’ll know how to write table-driven tests with subtests, check errors correctly, run tests in parallel, and compare complex structs. You’ll also know when not to use this technique.
2. Function to test
We’ll use a function that calculates a discounted price after applying a discount code to an order. This function has enough branches to be instructive:
// pricing/discount.go
package pricing
import (
"errors"
"strings"
)
var (
ErrNegativeTotal = errors.New("total must not be negative")
ErrInvalidCode = errors.New("discount code is not valid")
)
// ApplyDiscount returns the amount (in currency units) after applying the discount code.
func ApplyDiscount(total int, code string) (int, error) {
if total < 0 {
return 0, ErrNegativeTotal
}
switch strings.ToUpper(strings.TrimSpace(code)) {
case "":
return total, nil
case "SALE10":
return total * 90 / 100, nil
case "GIAM50K":
if total < 50000 {
return 0, nil
}
return total - 50000, nil
default:
return 0, ErrInvalidCode
}
}
The function has five behaviors to check. These are: negative total, no code, code SALE10, code GIAM50K (two branches), and an invalid code. Additionally, the code is normalized with TrimSpace and ToUpper.
3. Basic structure of table-driven tests
3.1. First version
A table-driven test has three parts: declare the table, loop through it, and compare results.
// pricing/discount_test.go
package pricing
import "testing"
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "no code", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "sale10 lowercase and whitespace", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k no negative", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
if got != tc.want {
t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
tc.name, tc.total, tc.code, got, tc.want)
}
}
}
Explanation:
testsis a slice of anonymous struct. Each element is a test case, consisting of inputs (total,code) and expected output (want).- The
namefield helps you identify which case failed. - The
forloop calls the function and compares results. The error message patternF(input) = got, want Xis a familiar convention in Go.
To add a new case, you just need to add one line to the table. This is the biggest advantage of this technique.
3.2. Problems with the first version
The code above works but has two drawbacks:
t.Fatalfstops the entire test. One failing case will prevent all following cases from running.- You can’t run a single case in isolation when debugging.
Subtests solve both problems.
4. Using subtests with t.Run
t.Run creates a subtest for each case. Each subtest has its own *testing.T, so t.Fatal only stops the current case.
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "no code", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "sale10 lowercase and whitespace", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k normal", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k no negative", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
tc.total, tc.code, got, tc.want)
}
})
}
}
You no longer need to print tc.name in the error message, since Go attaches the subtest name to the output. Run with -v:
go test ./pricing -v
=== RUN TestApplyDiscount
=== RUN TestApplyDiscount/no_code
=== RUN TestApplyDiscount/sale10
=== RUN TestApplyDiscount/sale10_lowercase_and_whitespace
=== RUN TestApplyDiscount/giam50k_normal
=== RUN TestApplyDiscount/giam50k_no_negative
--- PASS: TestApplyDiscount (0.00s)
--- PASS: TestApplyDiscount/no_code (0.00s)
--- PASS: TestApplyDiscount/sale10 (0.00s)
--- PASS: TestApplyDiscount/sale10_lowercase_and_whitespace (0.00s)
--- PASS: TestApplyDiscount/giam50k_normal (0.00s)
--- PASS: TestApplyDiscount/giam50k_no_negative (0.00s)
PASS
You can see that spaces in the name have been replaced with underscores. To run a single case in isolation, pass a regex to -run:
go test ./pricing -run 'TestApplyDiscount/giam50k' -v
This command only runs the two cases whose names start with giam50k. When a case fails, the output looks like:
--- FAIL: TestApplyDiscount (0.00s)
--- FAIL: TestApplyDiscount/sale10 (0.00s)
discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000
You immediately know which case failed, on which line, and with what input.
Note: Give cases short, unique names that are easy to type again on the terminal. If two cases have the same name, Go will automatically add a suffix like
#01, making it harder to run a specific case.
5. Checking errors in table tests
Cases that return errors should also be in the same table. Add a wantErr field of type error, then compare using errors.Is:
package pricing
import (
"errors"
"testing"
)
func TestApplyDiscount_Errors(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
wantErr error
}{
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "negative total", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
{name: "invalid code", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr != nil {
return // error already checked, no need to check return value
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
Explanation:
- For a successful case,
wantErrhas the zero valuenil. In that case,errors.Is(nil, nil)returnstrue. - If the function returns an error when you don’t expect one,
errors.Is(err, nil)returnsfalseand the test fails immediately. errors.Isstill works when an error is wrapped withfmt.Errorf("...: %w", err). Direct comparison witherr == tc.wantErrdoes not.
Many codebases use wantErr bool for simplicity. That approach only tells you whether an error occurred, not what the error is. You should prefer comparing with a sentinel error if your package defines one.
6. Running tests in parallel with t.Parallel
When test cases are slow (making I/O calls, heavy processing), you can run subtests in parallel:
func TestApplyDiscount_Parallel(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "no code", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
t.Parallel() tells the test runner that this subtest can run concurrently with other parallel subtests. The parent function will pause the subtests, finish the loop, then release all subtests to run at the same time.
Starting with Go 1.22, each for loop iteration creates a new tc variable, so the closure always captures the correct value. With Go 1.21 and earlier, you must copy the variable:
for _, tc := range tests {
tc := tc // needed for Go < 1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
If you omit the tc := tc line on older versions, all subtests will use the last case. The test still “passes” but actually only checks one scenario. This is a very hard bug to detect. Check the go line in your go.mod.
Note: Only use
t.Parallel()when cases don’t share mutable state, like global variables, shared temporary files, or the same database table. With a pure function likeApplyDiscount, the benefit is almost negligible. Don’t add it out of habit.
7. Comparing complex structs with go-cmp
When a function returns a nested struct, the != operator can’t be used on structs containing slices or maps. reflect.DeepEqual only returns true/false without telling you where the difference is. The go-cmp library solves this. Add it to your module:
go get github.com/google/go-cmp/cmp
// go.mod (excerpt)
require github.com/google/go-cmp v0.6.0
Suppose you have a function that builds an invoice:
// pricing/invoice.go
package pricing
type Invoice struct {
Customer string
Items []string
Total int
}
func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
final, err := ApplyDiscount(total, code)
if err != nil {
return Invoice{}, err
}
return Invoice{Customer: customer, Items: items, Total: final}, nil
}
Test it using cmp.Diff:
package pricing
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestBuildInvoice(t *testing.T) {
tests := []struct {
name string
customer string
items []string
total int
code string
want Invoice
}{
{
name: "with sale10 code",
customer: "Alice",
items: []string{"shirt", "hat"},
total: 200000,
code: "SALE10",
want: Invoice{Customer: "Alice", Items: []string{"shirt", "hat"}, Total: 180000},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("BuildInvoice() differs (-want +got):\n%s", diff)
}
})
}
}
cmp.Diff returns an empty string when two values are equal. If they differ, you get an easy-to-read diff:
BuildInvoice() differs (-want +got):
pricing.Invoice{
Customer: "Alice",
Items: {"shirt", "hat"},
- Total: 180000,
+ Total: 200000,
}
The minus sign is the expected value, the plus sign is the actual value. The convention (-want +got) should be stated clearly in the message so readers don’t have to guess.
8. Common pitfalls and when not to use this
Some common mistakes when writing table-driven tests:
- Table grows too large with too many flag fields. When the test struct has fields like
setupMock bool,skipValidation bool, orexpectCall int, and the loop body is full ofifstatements, you’re writing logic in your test. Time to split into separate test functions. - Using
t.Fataloutside oft.Run. One failing case will lose the results of all following cases. - Using a map instead of a slice without understanding the consequences. A
map[string]struct{...}prevents duplicate names and runs in random order, which makes it easier to uncover hidden dependencies between cases. However, output won’t be in a fixed order. Both approaches are acceptable as long as your team agrees. - Error messages missing the inputs. Writing just
t.Errorf("wrong")forces the debugger to open the test file and read it. Always print input, got, and want. - Forgetting edge cases. The table helps you spot missing cases at a glance. Review values like 0, negative numbers, empty strings, and values just at the threshold (for example,
total: 50000withGIAM50K).
You should not use table-driven tests when:
- Each case needs very different setup—for instance, mocks returning different data or different call sequences. Forcing them into one table just makes tests harder to read.
- You only have one or two cases. A straightforward test function will be clearer.
- You need to explore random inputs. Consider fuzz tests (
func FuzzXxx(f *testing.F)) available since Go 1.18. You can use your test table as a seed corpus viaf.Add.
9. Conclusion
Table-driven tests let you gather many test scenarios into a single data table and run them with a single loop. Combined with t.Run, each case becomes an independent subtest. You can run specific cases in isolation with -run, and a failing case won’t stop the others.
Key takeaways:
- Each case should have a
name, inputs, and expected output. - Check errors using
errors.Iswith awantErr errorfield. - Only use
t.Parallel()when truly needed, and be aware of loop variable semantics before Go 1.22. - Use
cmp.Diffwhen comparing complex structs. - When your table starts containing control flow logic, that’s a sign you should split the tests apart.