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:

  • tests is a slice of anonymous struct. Each element is a test case, consisting of inputs (total, code) and expected output (want).
  • The name field helps you identify which case failed.
  • The for loop calls the function and compares results. The error message pattern F(input) = got, want X is 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:

  1. t.Fatalf stops the entire test. One failing case will prevent all following cases from running.
  2. 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, wantErr has the zero value nil. In that case, errors.Is(nil, nil) returns true.
  • If the function returns an error when you don’t expect one, errors.Is(err, nil) returns false and the test fails immediately.
  • errors.Is still works when an error is wrapped with fmt.Errorf("...: %w", err). Direct comparison with err == tc.wantErr does 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 like ApplyDiscount, 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, or expectCall int, and the loop body is full of if statements, you’re writing logic in your test. Time to split into separate test functions.
  • Using t.Fatal outside of t.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: 50000 with GIAM50K).

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 via f.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.Is with a wantErr error field.
  • Only use t.Parallel() when truly needed, and be aware of loop variable semantics before Go 1.22.
  • Use cmp.Diff when comparing complex structs.
  • When your table starts containing control flow logic, that’s a sign you should split the tests apart.
golangtestingtable-driven testsunit tests