Skip to content
gohacks.dev
Testing

Testify Suites With Setup Teardown and Subtests in Go

Use testify suite in Go: embed suite.Suite, write SetupSuite, SetupTest and TearDownTest, run methods as tests, and see the exact hook order for subtests.

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

A testify suite brings a familiar xUnit shape to Go: a type with setup and teardown hooks and a method per test. It is useful when many tests share expensive or stateful setup, a database connection, a seeded cache, a started server, and you want that setup written once. This article shows the lifecycle with a lab on Go 1.25.6, testify v1.11.1, and explains where suites get awkward.

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

A suite and its hooks

suite_test.goGo
package main

import (
	"testing"

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

type StoreSuite struct {
	suite.Suite
	log  []string
	data map[string]int
}

func (s *StoreSuite) SetupSuite()   { s.log = append(s.log, "SetupSuite") }
func (s *StoreSuite) SetupTest()    { s.data = map[string]int{}; s.log = append(s.log, "SetupTest") }
func (s *StoreSuite) TearDownTest() { s.log = append(s.log, "TearDownTest") }
func (s *StoreSuite) TearDownSuite() {
	s.log = append(s.log, "TearDownSuite")
	s.T().Logf("hook order: %v", s.log)
}

func (s *StoreSuite) TestEmptyStore() {
	s.Empty(s.data)
}

func (s *StoreSuite) TestAddOne() {
	s.data["a"] = 1
	s.Len(s.data, 1)
}

func (s *StoreSuite) TestSubtests() {
	for _, n := range []int{1, 2} {
		s.Run("size", func() {
			s.data = map[string]int{}
			for i := range n {
				s.data[string(rune('a'+i))] = i
			}
			s.Len(s.data, n)
		})
	}
}

func TestStoreSuite(t *testing.T) { suite.Run(t, new(StoreSuite)) }

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
    hook order: [SetupSuite SetupTest TearDownTest SetupTest TearDownTest SetupTest TearDownTest TearDownSuite]
--- PASS: TestStoreSuite
    --- PASS: TestStoreSuite/TestAddOne
    --- PASS: TestStoreSuite/TestEmptyStore
    --- PASS: TestStoreSuite/TestSubtests
        --- PASS: TestStoreSuite/TestSubtests/size
        --- PASS: TestStoreSuite/TestSubtests/size#01

The suite embeds suite.Suite, which provides s.T() and the assertion helpers (s.Equal, s.Len, s.Empty and so on). It keeps a log of hook calls in a field, and a map as the fixture. TestStoreSuite is the ordinary Go test that starts it: suite.Run(t, new(StoreSuite)). The Go test runner sees one test, TestStoreSuite; testify discovers the methods whose names start with Test using reflection and runs each as a subtest.

The hook order

The log line printed by TearDownSuite reads:

Text
[SetupSuite SetupTest TearDownTest SetupTest TearDownTest SetupTest TearDownTest TearDownSuite]

Read it as nested scopes. SetupSuite runs once before any test. For each test method, SetupTest runs before it and TearDownTest after it. TearDownSuite runs once at the end. Three methods, three pairs. The available hooks, in the order they run for one method, are:

HookRunsTypical use
SetupSuiteOnce, before all testsStart a container, open a connection
SetupTestBefore each test methodReset state, begin a transaction
BeforeTest(suite, test)Before each, after SetupTestLogging, metrics
SetupSubTestBefore each s.Run subtestPer-subtest state
TearDownSubTestAfter each subtestPer-subtest cleanup
AfterTest(suite, test)After each, before TearDownTestLogging
TearDownTestAfter each test methodRoll back, delete files
TearDownSuiteOnce, after all testsStop the container, close connections

Only the hooks you define run. The two that matter in practice are SetupSuite/TearDownSuite for per-run setup and SetupTest/TearDownTest for isolation.

Method order

The subtest listing shows TestAddOne, TestEmptyStore, TestSubtests: alphabetical. Testify iterates methods in the order reflection reports them, which is sorted by name, not by position in the file. Tests that depend on order are a bug waiting to happen, and the suite does not shuffle for you. If you need to catch the dependence, run the suite with -testify.m to select methods and try them alone, or restructure the tests so that SetupTest gives every method a clean state. The lab does the latter: each test starts from a new empty map.

Subtests inside a suite

s.Run("size", func() {...}) creates a subtest. The listing shows TestStoreSuite/TestSubtests/size and size#01 for the two iterations. Two details differ from plain t.Run.

First, the function takes no *testing.T. Inside it, s.T() returns the subtest's T, and the suite's assertions use it. Do not capture the outer t in a closure and use it in the subtest, or failures attach to the wrong test.

Second, SetupTest does not run again for a subtest. The log shows three pairs for three methods even though TestSubtests created two subtests. That is why the test body above rebuilds the map itself for each subtest. If you want per-subtest setup, define SetupSubTest and TearDownSubTest, which testify supports from version 1.8.2.

Running part of a suite

go test -run TestStoreSuite/TestAddOne selects one method, by the subtest name. Testify also adds a flag, -testify.m, that takes a regular expression matched against method names: go test -run TestStoreSuite -testify.m 'AddOne'. The first form works with every Go tool and IDE; prefer it.

Where suites help and where they hurt

A suite helps when:

  • setup is expensive and shared (SetupSuite), and per-test reset is cheap (SetupTest), as with a database container and a transaction per test;
  • a team already thinks in xUnit terms and wants the structure;
  • tests need a large fixture accessible as fields.

It hurts when:

  • State leaks between methods. Fields persist across tests. A test that sets s.user = ... and a later test that reads it make the suite order-dependent. SetupTest must reset everything, and that discipline is the cost of the style.
  • Parallelism is awkward. Tests in a suite share the receiver, so t.Parallel() inside a method does not do what you want. Testify's suite does not run methods in parallel by default; and sharing the receiver's fields between concurrent methods needs its own care.
  • The framework hides the flow. A reader has to know which hooks exist and when they run. Plain Go with a helper function and t.Cleanup is more explicit; see the migration article for the mapping.
  • Names change in tooling. CI dashboards and the -run flag see TestStoreSuite/TestAddOne, not TestAddOne.
  • Shuffle and race flags help less. -shuffle randomises top-level tests, and the suite is one of them; the methods inside stay in alphabetical order.

A pattern that works: transaction per test

The strongest use is integration tests on a database. SetupSuite opens the connection and runs migrations once. SetupTest begins a transaction and stores it in a field; TearDownTest rolls it back; each test runs its statements through that transaction. Every method starts from the same data and finishes in microseconds. It is the same technique as rollback tests, packaged with hooks.

Keep the fixture's mutable parts inside the transaction, not in the struct, so there is nothing to reset. Make the suite skip itself when the database is not available, with a message that names the environment variable, and put it behind a build tag if it needs Docker; see the integration tag article.

Keeping suites small

A suite per package is a common mistake: it grows to thirty methods and a dozen fields, and nobody can say which test needs which. Keep each suite to one subject, a repository, a handler, a service, and share setup through embedding or helper functions. If two suites need the same fixture, put it in a function that returns it and call that function from both SetupSuite methods, instead of building a base suite with inheritance-like layers. Embedded suites work in Go, but a chain of three base suites is hard to debug when a hook runs in an order you did not expect.

For the cleanup of resources, prefer s.T().Cleanup inside SetupTest to a matching line in TearDownTest: the cleanup is registered next to the thing it undoes, so the two cannot drift apart. The Go documentation for the testing package describes cleanup ordering.

Limits of the lab

The lab uses an in-memory map, so it demonstrates ordering, not isolation of a real resource. The hooks and their order are testify v1.11.1 behaviour and the SetupSubTest hooks need a version from after 1.8.2. The package is documented at pkg.go.dev/github.com/stretchr/testify/suite. For the assertion vocabulary the suite exposes, see the assertions tutorial.

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.