Contents

gotest migrate ./... rewrites most of a testify/suite codebase automatically: struct renames, lifecycle hooks, assertion calls, imports, and the suite.Run boilerplate. The catch is a handful of patterns it can’t convert safely and leaves for manual review. This guide shows what the tool converts, what it leaves for you, and how to migrate package by package without breaking the rest of the codebase.

testify/suite is the most widely used Go test suite framework, and for good reason. It gives you struct-based test grouping and lifecycle hooks on top of the standard library. Many teams have hundreds of suites built on it. This guide is for teams that have decided to try gotest alongside or in place of those suites.

This is not a case for why you should migrate. If you’re still evaluating, Why Go’s testing Package Needs a Suite Layer and Code Generation vs Reflection in Go Test Frameworks cover the design differences. This post assumes you’ve already decided to give it a try. If you’re starting fresh rather than migrating, Your First Go Test Suite in 10 Minutes is the better entry point.

What won’t migrate automatically

gotest migrate handles the mechanical bulk of the transformation. Four patterns are flagged with // TODO(gotest-migrate) comments — or left to the compiler — instead of being silently rewritten:

Each of these gets a detailed treatment in the “What needs manual review” section below. The tool rewrites files in place, so run it on a clean working tree — git diff is your preview, git checkout your undo.

testify to gotest: what maps to what

The structural concepts are the same in both frameworks. Suites are structs, tests are methods, lifecycle hooks run at predictable points. The names change, and the wiring changes, but the mental model carries over.

testify/suitegotestNotes
XxxSuiteXxxTestSuiteStruct name must end in TestSuite
suite.Suite embed(removed)No base type to embed
SetupSuite()BeforeAll(t *gotest.T)Receives a t parameter
TearDownSuite()AfterAll(t *gotest.T)Registered via t.Cleanup
SetupTest()BeforeEach(t *gotest.T)Receives a t parameter
TearDownTest()AfterEach(t *gotest.T)Deferred, runs even on t.Fatal
func (s *S) TestX()func (s *S) TestX(t *gotest.T)Test methods receive t
s.Require().Equal(a, b)gotest.Equal(t, a, b)Standalone generic functions
s.NoError(err)gotest.NoError(t, err)Same for all assertions
suite.Run(t, new(S))(removed)Generated automatically

The biggest conceptual shift is that test methods now receive a t parameter. In testify, the test’s *testing.T is buried inside the embedded suite.Suite and accessed via s.T(). In gotest, it’s an explicit parameter, which means assertions are standalone function calls rather than method calls on the suite.

Before and after: a full suite migration

Here’s a complete testify/suite test file and what it looks like after migration — the tool’s rewrite plus the handful of TODO fixes it flags (the direct s.Equal/s.Contains/s.ErrorIs calls below):

before: user_test.go (testify/suite)
package user

import (
    "testing"

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

type UserServiceSuite struct {
    suite.Suite
    db  *TestDB
    svc *UserService
}

func (s *UserServiceSuite) SetupTest() {
    s.db = NewTestDB(s.T())
    s.svc = NewUserService(s.db)
}

func (s *UserServiceSuite) TearDownTest() {
    s.db.Close()
}

func (s *UserServiceSuite) TestCreate() {
    err := s.svc.Create("alice@example.com")
    s.Require().NoError(err)

    user, err := s.svc.Get("alice@example.com")
    s.Require().NoError(err)
    s.Equal("alice@example.com", user.Email)
}

func (s *UserServiceSuite) TestCreateDuplicateEmail() {
    s.svc.Create("alice@example.com")
    err := s.svc.Create("alice@example.com")
    s.Require().Error(err)
    s.Contains(err.Error(), "duplicate")
}

func (s *UserServiceSuite) TestGetNotFound() {
    _, err := s.svc.Get("nobody@example.com")
    s.Require().Error(err)
    s.ErrorIs(err, ErrNotFound)
}

func TestUserServiceSuite(t *testing.T) {
    suite.Run(t, new(UserServiceSuite))
}
after: user_test.go (gotest)
package user

import (
    "github.com/mvrahden/go-test/pkg/gotest"
)

type UserServiceTestSuite struct {
    db  *TestDB
    svc *UserService
}

func (s *UserServiceTestSuite) BeforeEach(t *gotest.T) {
    s.db = NewTestDB(t.T())
    s.svc = NewUserService(s.db)
}

func (s *UserServiceTestSuite) AfterEach(t *gotest.T) {
    s.db.Close()
}

func (s *UserServiceTestSuite) TestCreate(t *gotest.T) {
    err := s.svc.Create("alice@example.com")
    gotest.NoError(t, err)

    user, err := s.svc.Get("alice@example.com")
    gotest.NoError(t, err)
    gotest.Equal(t, "alice@example.com", user.Email)
}

func (s *UserServiceTestSuite) TestCreateDuplicateEmail(t *gotest.T) {
    s.svc.Create("alice@example.com")
    err := s.svc.Create("alice@example.com")
    gotest.Error(t, err)
    gotest.Contains(t, err.Error(), "duplicate")
}

func (s *UserServiceTestSuite) TestGetNotFound(t *gotest.T) {
    _, err := s.svc.Get("nobody@example.com")
    gotest.ErrorIs(t, err, ErrNotFound)
}

Notice what disappeared:

And one behavioral change to be aware of: in the “before” code, s.Equal(...), s.Contains(...), and s.ErrorIs(...) are called directly on the suite, which uses assert semantics: the test continues after a failure. Only the s.Require().NoError(...) calls stop. In the “after” code, all gotest assertions stop on failure. This is usually what you want, but if your tests relied on continuing past a failed assertion, review those cases.

And what changed shape:

What changes beyond the syntax

Once a suite is migrated, several things change beyond the syntax:

Running the migration tool

gotest migrate automates the mechanical parts of this transformation. Point it at your packages and it rewrites the source files in place:

# migrate all packages
$ gotest migrate ./...

# migrate a specific package
$ gotest migrate ./pkg/user

There is no dry-run mode — the tool writes rewritten files directly. Run it on a clean git tree and use git diff to review every change (and git checkout to back out).

The tool performs an AST-level transformation, not a text find-and-replace. It parses your Go source files, identifies testify/suite patterns, and rewrites them while preserving comments, formatting, and non-suite code in the same file.

What the tool handles

The migration tool covers the common cases that make up the bulk of a typical migration:

  1. Renames the suite struct to follow the *TestSuite convention.
  2. Renames lifecycle hooks: SetupSuite to BeforeAll, TearDownSuite to AfterAll, SetupTest to BeforeEach, TearDownTest to AfterEach.
  3. Transforms assertion calls: s.Require().Equal(a, b) and s.Assert().Equal(a, b) become gotest.Equal(t, a, b), as do require.Equal(s.T(), a, b)-style calls inside suite methods. Direct s.Equal(a, b) calls are annotated with a TODO instead (see below).
  4. Removes the suite.Suite embed from the struct.
  5. Removes the suite.Run boilerplate function.
  6. Updates imports: removes testify/suite, adds gotest.

What needs manual review

The tool handles the 90% case. For the remaining edge cases, it leaves // TODO(gotest-migrate) comments so you can find and address them:

Assertions: the biggest diff

The assertion changes will touch more lines than anything else. In testify, assertions are methods on the suite or on s.Require(). In gotest, they’re standalone generic functions that take t as the first argument.

The good news: it’s a mechanical transformation. The assertion names are almost identical, and the argument order is consistent. Here are the most common mappings:

testifygotest
s.Equal(expected, actual)gotest.Equal(t, expected, actual)
s.Require().NoError(err)gotest.NoError(t, err)
s.Contains(str, sub)gotest.Contains(t, str, sub)
s.Len(slice, n)gotest.Len(t, slice, n)
s.True(cond)gotest.True(t, cond)
s.ErrorIs(err, target)gotest.ErrorIs(t, err, target)
s.Nil(ptr)gotest.Nil(t, ptr)
s.NotNil(ptr)gotest.NotNil(t, ptr)

Nil and NotNil deserve a note: gotest’s versions are type-guarded. They accept only nilable types — pointers, interfaces, slices, maps, channels, functions — and fail with a guard error on anything else, pointing you to Zero/NotZero for comparable value types. testify’s Nil accepts any value and reports a plain assertion failure. If your code calls s.Nil on non-nilable values, switch those call sites to gotest.Zero.

One difference worth noting: testify distinguishes between s.Assert() (continues on failure) and s.Require() (stops on failure). Calling assertions directly on the suite (s.Equal(...), s.Contains(...)) also continues on failure, because the suite embeds *assert.Assertions. Only s.Require().Equal(...) stops. All gotest assertions stop on failure, like Require. This is a deliberate choice: a test that continues after a failed precondition typically produces confusing follow-on errors. If you need soft assertions, you can use t.Errorf directly.

Another difference: gotest assertions are generic. gotest.Equal[V any](t, expected, actual V) catches type mismatches at compile time. In testify, s.Equal(42, "42") compiles and fails at runtime. In gotest, gotest.Equal(t, 42, "42") is a compile error.

Migrating gradually, package by package

You don’t have to migrate everything at once. gotest suites and func Test* functions coexist in the same package. A practical approach for larger codebases:

  1. Start with one package. Pick a package with a few well-understood suites. Run gotest migrate ./pkg/user and verify the tests pass.
  2. Run both side by side. Unmigrated packages keep using testify under go test. Migrated suites run under gotest — the two runners partition the work and ignore each other’s tests, so a complete run is both commands: go test ./... for the stdlib/testify half, gotest ./... for the suites.
  3. Migrate package by package. There’s no deadline. Each package is independent. A half-migrated codebase works fine.
  4. Remove testify when ready. Once the last suite is migrated, drop the testify dependency from go.mod.

The linter can help track progress. gotest lint flags every remaining testify import (“testify import … — consider migrating to gotest”), which makes half-migrated packages easy to list. Wiring that check into your pipeline keeps half-migrated packages from lingering; Go Tests in GitHub Actions covers the CI setup.

Common questions

Do I need to rewrite all my assertion helpers?

Only the ones that use testify’s assertion API. If you have helper functions that accept *testing.T and use the standard library’s t.Fatal or t.Errorf, those work unchanged. Functions that call require.Equal(t, ...) need their imports and calls updated to gotest.Equal(t, ...).

What about testify/mock?

testify/mock is a separate package from testify/suite. You can migrate your suites to gotest while continuing to use testify/mock (or gomock, mockery, moq, or any other mocking tool). Mocking is orthogonal to test organization.

Can I use *testing.T instead of *gotest.T?

Yes. All lifecycle hooks and test methods accept either *gotest.T or *testing.T. Using *gotest.T gives you access to t.When() and t.It() for BDD structure, but it’s not required. You can migrate to *testing.T first and add BDD structure later.

What if I have hundreds of suites?

gotest migrate ./... processes all packages in one pass. Review the // TODO(gotest-migrate) comments it leaves, fix the edge cases, and run your tests. For large codebases, doing this package by package is safer, but the tool handles batch migration too.

Run gotest migrate on your first testify suite today.

gotest migrate ./...