Skip to content
gohacks.dev
Testing

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.

7 min read
Summarize in AI
Flat illustration of a staircase from one version to the next.

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

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)) }

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

std_test.goGo
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() {}
Shell
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\)//'
Text
# 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 TearDownSuite

The mapping

TestifyStandard library
SetupSuite / TearDownSuiteTestMain(m *testing.M): set up, code := m.Run(), tear down, os.Exit(code)
SetupTestA helper newStore(t) that builds the fixture and returns it
TearDownTestt.Cleanup(func(){...}) registered inside that helper
SetupSubTest / TearDownSubTestThe same helper, called inside t.Run
Struct fields as shared stateLocal variables returned by the helper
s.Equal, s.NoErrorif 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

  1. Run the suite and record the pass list. go test -v -run TestStoreSuite gives the names to preserve.
  2. Convert one suite at a time, in its own pull request, keeping the assertions as testify's assert and require at first. This isolates the structural change from the assertion change.
  3. 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.
  4. Check ordering assumptions. Suites run methods alphabetically; plain tests run in source order, and -shuffle randomises them. Tests that depended on order surface now.
  5. Add t.Parallel() to the tests that can take it, which is usually the reason for the migration.
  6. Remove the assertions later, if at all; the removal article covers it. Dropping testify's suites and keeping assert is 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 SetupTest is reported against the test. A panic in a helper is reported the same way, but a panic in TestMain aborts the whole package with no test names. Keep TestMain short 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 and t.
  • s.Require() and s.Assert(). These map to require.New(t) and assert.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 is TestAddOne. 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.

Taggedmigration
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.