Skip to content
gohacks.dev
Testing

Testify With Table Tests and Subtests in Go Packages

Combine testify with table-driven subtests in Go: bind assert.New to the subtest t, name each case, keep the table typed and read the failure for one row.

6 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Table tests and testify fit together without friction, with one trap: where you create the assertion object. This article shows the combination on Go 1.25.6, the failure it produces for one bad row, and the details that keep the output readable.

Tested with Go 1.25.6, testify v1.11.1, macOS on Apple Silicon (arm64)

The table

tbl_test.goGo
package main

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func Clamp(n, lo, hi int) int { return min(max(n, lo), hi) }

func TestClamp(t *testing.T) {
	cases := []struct {
		name            string
		n, lo, hi, want int
	}{
		{"inside", 5, 0, 10, 5},
		{"below", -3, 0, 10, 0},
		{"above", 99, 0, 10, 10},
		{"at low edge", 0, 0, 10, 0},
		{"wrong on purpose", 11, 0, 10, 11},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			is := assert.New(t) // bind t once, drop the argument from every call
			is.Equal(c.want, Clamp(c.n, c.lo, c.hi))
		})
	}
}

func main() {}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|=== NAME|PASS|ok|FAIL$|FAIL\s|exit status)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_0-9]+\.go:[0-9]+: #    #; s/\x1b\[[0-9;]*m//g; s#/[^[:space:]]*/([a-z_0-9]+\.go)#\1#g; s/[[:space:]]+$//' | grep -vE '^\s*$'
Text
        	Error Trace:	tbl_test.go:25
        	Error:      	Not equal:
        	            	expected: 11
        	            	actual  : 10
        	Test:       	TestClamp/wrong_on_purpose
--- FAIL: TestClamp
    --- PASS: TestClamp/inside
    --- PASS: TestClamp/below
    --- PASS: TestClamp/above
    --- PASS: TestClamp/at_low_edge
    --- FAIL: TestClamp/wrong_on_purpose

TestClamp has five cases. Each has a name, three inputs and a wanted result; the last row is wrong on purpose. The loop runs each as a subtest with t.Run(c.name, ...), and inside the subtest the first line is is := assert.New(t). The call is.Equal(c.want, Clamp(c.n, c.lo, c.hi)) then needs no t.

The output shows four passing subtests and one failure:

Text
Error:      	Not equal:
            	expected: 11
            	actual  : 10
Test:       	TestClamp/wrong_on_purpose

The name in the Test: field is the row name with spaces replaced by underscores. Together with the expected and actual values, that is enough to find the row without printing the inputs. If the failure needs them, add a message: is.Equal(c.want, got, "Clamp(%d, %d, %d)", c.n, c.lo, c.hi).

Where to bind t

The placement of assert.New(t) is the trap. Inside the subtest function it binds the subtest's t, and a failure is attributed to that subtest. Outside the loop, it binds the parent's t:

Go
is := assert.New(t) // wrong place
for _, c := range cases {
	t.Run(c.name, func(t *testing.T) {
		is.Equal(c.want, Clamp(c.n, c.lo, c.hi)) // reports against the parent
	})
}

The failure is real but is reported on TestClamp, the subtest passes, and -run TestClamp/wrong_on_purpose seems to pass. Always create the object, or use the package functions with the subtest's t, inside the closure.

Keeping the table readable

Name every case. The name is the identifier in output and in -run. Descriptive names such as at low edge are more useful than case 4.

Use a struct, not parallel slices. Fields have names; positions do not. For many fields, align them one per line.

Expected first. is.Equal(c.want, got) follows testify's (expected, actual) order, so the labels in the failure are correct.

One behaviour per row. A row that checks three things cannot say which failed, unless each has its own message.

Generate structure, not logic. Do not compute the expected value with the same formula as the code under test; write the literal.

Parallel rows

Add t.Parallel() as the first line of the subtest when the rows are independent. With Go 1.22 or later in go.mod, the closure can use c directly because each iteration has its own variable. On older language versions, copy it first with c := c. The assertion object is created after t.Parallel(), inside the subtest, so it is bound to the right t. See the parallel tests article.

Failure output when the table is large

A table of forty rows with one failure is easy to read, since the failing row's name is in the header. A table where twenty rows fail because of one bug is not, because twenty blocks of expected and actual flood the output. Two habits help. First, run with -failfast while debugging, which stops after the first failing test, or use require inside the subtest so each row stops at its first failure. Second, put the likely cause into the row names, so a pattern shows: if all the negative rows fail, the bug is probably in the sign handling.

The -run flag selects rows by name, with the slash separating levels: go test -run 'TestClamp/wrong' runs only matching subtests. Spaces in names become underscores, so a name like at low edge is addressed as at_low_edge. Write names that need no escaping, and avoid slashes inside them, since a slash is read as another level.

Rows that need setup

Some rows need a fixture: a temporary file, a configured server, a seeded map. Add a setup func(t *testing.T) field or build the fixture in the loop body from row data. Keep the fixture creation inside the subtest, so t.TempDir() and t.Cleanup attach to it, and each row gets its own directory. A shared fixture created outside the loop is acceptable when rows only read it.

For rows with errors, include wantErr error in the struct and use is.ErrorIs(err, c.wantErr). A nil wantErr means no error is expected, which you can check with is.NoError(err) in an if branch. This keeps success and failure rows in one table without separate loops.

Typed expectations

Expected values are often structures. Write them as literals in the table, not computed, so the table is a specification. When the structure is large, share a base value and modify a copy per row, or use the builder pattern from test data builders. With testify, is.Equal on structures prints a diff, which is how a mismatch in one field of fifteen becomes readable; the format is shown in the diff article.

For slices where order does not matter, use is.ElementsMatch(c.want, got) and not Equal; the order of results from maps or goroutines is not defined.

A note on helper functions inside rows

Some tables compute inputs with a function, such as strings.Repeat("a", 300) for a length limit. That is fine; the row name should say what the input is, 300 characters, since the value itself is not visible in the output. For a big input, avoid dumping it into the failure message. Print its length and a prefix. Testify prints the values it was given, so a failure comparing two 300-character strings produces two 300-character lines; keep expected values short enough to read, and test the property (length, prefix, absence of a character) when the full value is not the point.

Table design checklist

  1. Every row has a unique, descriptive name.
  2. The assertion object is created inside the subtest.
  3. Expected values are literals.
  4. Rows are independent, so t.Parallel() is an option.
  5. Error rows state the expected error value, not just that there is one.
  6. The table covers the boundaries: empty, one, many, edges, invalid.

When not to use testify here

For a table of simple comparisons the standard library is as short: if got := Clamp(...); got != c.want { t.Errorf("Clamp(%d, %d, %d) = %d, want %d", ...) }. Testify pays off when the checks are richer, structures, errors, JSON, collections. The base pattern and its variations are in table-driven tests, and the library comparison in the libraries roundup. The package is documented at pkg.go.dev/github.com/stretchr/testify/assert, and the subtest naming rules in the Go blog post Using Subtests and Sub-benchmarks.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.