Migrate From Testify Suites to the Standard Library
Move testify suites to plain Go tests: TestMain for suite hooks, a helper plus t.Cleanup for SetupTest, subtests for methods. Both versions run, order shown.

Testify suites give Go tests the shape of xUnit: a struct with SetupSuite, SetupTest, TearDownTest, TearDownSuite and a method per test. They work. Teams leave them for the same reasons, a framework that every newcomer has to learn, test methods that cannot be run in parallel with t.Parallel in the usual way, and a fixture shared through struct fields, where mutation by one test leaks to the next. The standard library covers each hook with a plain function. This article maps the hooks one to one and runs both versions on Go 1.25.6.
Tested with Go 1.25.6, testify v1.11.1, macOS on Apple Silicon (arm64)
Before: the suite
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)) }The suite embeds suite.Suite, keeps its fixture in struct fields and records which hook ran. suite.Run discovers the methods starting with Test and runs them in alphabetical order.
After: functions and a helper
package main
import (
"os"
"testing"
)
var log []string
func TestMain(m *testing.M) {
log = append(log, "SetupSuite")
code := m.Run()
log = append(log, "TearDownSuite")
println("hook order:", join(log))
os.Exit(code)
}
func join(s []string) string {
out := ""
for i, x := range s {
if i > 0 {
out += " "
}
out += x
}
return out
}
// newStore replaces SetupTest and TearDownTest: construction and t.Cleanup.
func newStore(t *testing.T) map[string]int {
t.Helper()
log = append(log, "SetupTest")
t.Cleanup(func() { log = append(log, "TearDownTest") })
return map[string]int{}
}
func TestEmptyStore(t *testing.T) {
data := newStore(t)
if len(data) != 0 {
t.Fatal("not empty")
}
}
func TestAddOne(t *testing.T) {
data := newStore(t)
data["a"] = 1
if len(data) != 1 {
t.Fatalf("len = %d", len(data))
}
}
func TestSubtests(t *testing.T) {
for _, n := range []int{1, 2} {
t.Run("size", func(t *testing.T) {
data := newStore(t)
for i := range n {
data[string(rune('a'+i))] = i
}
if len(data) != n {
t.Fatalf("len = %d, want %d", len(data), n)
}
})
}
}
func main() {}echo "# testify suite"
go test -count=1 -v -run 'TestStoreSuite' . 2>&1 | grep -E '^ +[a-z_0-9]+\.go:[0-9]+: hook order|^--- ' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_0-9]+\.go:[0-9]+: # #'
echo
echo "# standard library, same tests"
go test -count=1 -v -run 'TestEmptyStore|TestAddOne|TestSubtests' . 2>&1 | grep -E 'hook order|^--- ' | sed -E 's/ \([0-9.]+s\)//'# testify suite
hook order: [SetupSuite SetupTest TearDownTest SetupTest TearDownTest SetupTest TearDownTest TearDownSuite]
--- PASS: TestStoreSuite
# standard library, same tests
--- PASS: TestEmptyStore
--- PASS: TestAddOne
--- PASS: TestSubtests
hook order: SetupSuite SetupTest TearDownTest SetupTest TearDownTest SetupTest TearDownTest SetupTest TearDownTest TearDownSuiteThe mapping
| Testify | Standard library |
|---|---|
SetupSuite / TearDownSuite | TestMain(m *testing.M): set up, code := m.Run(), tear down, os.Exit(code) |
SetupTest | A helper newStore(t) that builds the fixture and returns it |
TearDownTest | t.Cleanup(func(){...}) registered inside that helper |
SetupSubTest / TearDownSubTest | The same helper, called inside t.Run |
| Struct fields as shared state | Local variables returned by the helper |
s.Equal, s.NoError | if got != want { t.Errorf(...) }, or assert.New(t) while migrating |
s.Run("name", func(){...}) | t.Run("name", func(t *testing.T){...}) |
s.T() | The t parameter |
suite.Run(t, new(S)) | Nothing: functions are discovered by go test |
What the output shows
The first block is the suite: SetupSuite, then SetupTest and TearDownTest around each of the three test methods, then TearDownSuite. Three pairs. The s.Run subtests inside TestSubtests did not trigger SetupTest themselves; the method's single setup covered both. That is testify's design, and the SetupSubTest hook exists for the other behaviour.
The second block is the same behaviour in plain functions: SetupSuite from TestMain, then pairs of SetupTest and TearDownTest, then TearDownSuite. There are four pairs, not three, and the difference is real. In the standard version the helper is called inside each subtest, because that is where the fixture belongs, so TestSubtests builds two fixtures and the two plain tests build one each. The standard version gives each subtest its own fixture, which is safer; the suite shared one across both. If your tests relied on subtests seeing each other's state, they will fail after migration, and they should: that dependency was a latent bug.
TestMain needs one detail. It must call os.Exit(code) with the result of m.Run() in Go versions before 1.15, and since then returning from TestMain exits with that code; calling os.Exit explicitly as the lab does is still correct and keeps the teardown order visible. Only use TestMain for work that has to happen once per package, such as starting a container. Per-test setup belongs in the helper, so that -run for a single test does not pay for unrelated setup.
A safe order of work
- Run the suite and record the pass list.
go test -v -run TestStoreSuitegives the names to preserve. - Convert one suite at a time, in its own pull request, keeping the assertions as testify's
assertandrequireat first. This isolates the structural change from the assertion change. - Rewrite the hooks following the table, and run the old and new together in the same package for a day if you can, comparing the names of tests that ran.
- Check ordering assumptions. Suites run methods alphabetically; plain tests run in source order, and
-shufflerandomises them. Tests that depended on order surface now. - Add
t.Parallel()to the tests that can take it, which is usually the reason for the migration. - Remove the assertions later, if at all; the removal article covers it. Dropping testify's suites and keeping
assertis a legitimate end state.
Parallel tests after the move
The main practical gain is parallelism. In a suite, methods share the struct, so running them concurrently needs care, and testify does not run methods in parallel by default. After migration each test builds its own fixture in a helper, so adding t.Parallel() is safe wherever the fixture is private. Check two things before you add it. A fixture that wraps a shared external resource, such as one database schema, must be isolated per test or the tests must stay sequential; see parallel tests. And a TestMain that starts a container is shared by every test in the package, so the tests running in parallel share it too.
Pitfalls seen in migrations
- A hook that panics. In a suite, a panic in
SetupTestis reported against the test. A panic in a helper is reported the same way, but a panic inTestMainaborts the whole package with no test names. KeepTestMainshort and defensive. - Struct fields used as globals. Suite fields that tests read from each other become package variables if you copy them across carelessly. Move them into the value the helper returns.
- Method receivers calling other methods. Suite methods often call helper methods on
s. Turn them into plain functions that take the fixture andt. s.Require()ands.Assert(). These map torequire.New(t)andassert.New(t); keep them for the first pass.- Test names in CI. Dashboards and flaky-test trackers key on
TestStoreSuite/TestAddOne. After migration the name isTestAddOne. Plan for the history to restart, or add a mapping. - Build tags and file layout. Suites often live in
*_suite_test.go; renaming is optional but makes the history clearer.
What you gain and what you lose
You lose a convention that some people find tidy and a few hooks that have no direct equivalent, such as SetupSubTest (use the helper in the subtest) and HandleStats (use -json). You gain one fewer dependency in the part of the code that everyone reads, tests that start as ordinary functions, -run that works on a single test without constructing a suite, and a path to parallelism. Whether that is worth a migration depends on how many suites you have and how much they hurt. For two small suites, leave them. For forty, a mechanical conversion in steps, following the table, takes days and not weeks.
When to keep a suite
A suite fits when there is a lot of shared, expensive, stateful setup and a team that likes the structure: integration tests against a database where SetupSuite creates it and SetupTest truncates tables. The standard library does that with TestMain and a helper, with more lines. If the suite is small and the team is productive, there is no urgency; the cost is a dependency and a convention. The decision is easier to make once you have seen both versions run, as above. The testing package's TestMain is documented in the package reference, and the suite package at pkg.go.dev/github.com/stretchr/testify/suite. Related reading: TestMain and cleanup and temp directories.


