Confession: I spent four years writing Go tests with testify before I sat down and asked whether I actually needed it. The answer turned out to be “for some things, yes; for most things, the standard library is fine, and I was just used to typing assert.Equal.” This post is what I figured out, with code, after migrating one large service away from testify (mostly) and a smaller one onto it.
If you’re new to Go and the only test framework you’ve heard of is testify, this post will save you some reading. If you’ve been using it for years, this post is the conversation I wish my older coworkers had with me when I started.
Short answer: I still use assert and require for value comparisons in a few places. I don’t use mock anymore, and I gave up on suite. The real win in 2026 is that the standard testing package plus a couple of small libraries cover 95% of what I used to reach for testify for.
What testify actually gives you
Testify is three packages bundled together: assert, require, mock, plus the suite runner. Each one solves a different real problem.
assert and require give you readable expectation helpers. Instead of writing if got != want { t.Errorf("got %v, want %v", got, want) }, you write assert.Equal(t, want, got). The output on failure includes both values and a diff for structs.
mock gives you a runtime mock object generator. You declare expected calls, run your code, and assert at the end that the mock was called as expected.
suite gives you a test class with SetupTest, TearDownTest, and shared state, like xUnit-style frameworks in other languages.
These are real conveniences. They’re also opinions about how testing should work, and the opinions don’t always age well in Go specifically.
assert vs require: the only rule I still follow
If you take one thing from this post: assert lets the test keep running after a failure, require stops it. That distinction matters more than people give it credit for.
Here’s a test where I want to check several independent things on a response:
func TestUserResponse(t *testing.T) {
resp := getUser(t, 42)
assert.Equal(t, 42, resp.ID)
assert.Equal(t, "Qasim", resp.Name)
assert.NotEmpty(t, resp.Email)
assert.False(t, resp.Deleted)
}
If resp.Name is wrong, I still want to know whether resp.Email is also wrong. Four assert calls, four independent checks, one test run.
But here’s a test where the second check is meaningless if the first one fails:
func TestParseConfig(t *testing.T) {
cfg, err := config.Parse("config.yaml")
require.NoError(t, err) // if this fails, cfg is nil
require.NotNil(t, cfg) // sanity check before dereferencing
assert.Equal(t, "production", cfg.Env)
assert.Equal(t, 5432, cfg.DBPort)
}
If Parse errored, cfg is nil, and dereferencing it in the next assertion would panic. require.NoError halts the test before that happens.
The rule I live by: use require for preconditions (parsing succeeded, the response was non-nil, the database returned something) and assert for the actual things you’re testing. If you find yourself writing require.Equal for the thing under test, you probably want assert so you see all failures, not just the first one.
Where I stopped using mock
I used testify/mock for years. I don’t anymore, and the reason is that Go’s interface system makes it unnecessary.
Here’s what testify mocks look like:
type MockEmailer struct {
mock.Mock
}
func (m *MockEmailer) Send(to, subject, body string) error {
args := m.Called(to, subject, body)
return args.Error(0)
}
func TestSignup(t *testing.T) {
m := new(MockEmailer)
m.On("Send", "[email protected]", "Welcome", mock.Anything).Return(nil)
svc := signup.New(m)
err := svc.Register("[email protected]")
require.NoError(t, err)
m.AssertExpectations(t)
}
That works. It’s also a lot of ceremony for what’s essentially “did the function call Send with the right arguments.” Here’s the version I write now, with no testify:
type fakeEmailer struct {
sent []sentEmail
}
type sentEmail struct{ to, subject, body string }
func (f *fakeEmailer) Send(to, subject, body string) error {
f.sent = append(f.sent, sentEmail{to, subject, body})
return nil
}
func TestSignup(t *testing.T) {
f := &fakeEmailer{}
svc := signup.New(f)
err := svc.Register("[email protected]")
if err != nil {
t.Fatal(err)
}
if len(f.sent) != 1 {
t.Fatalf("want 1 email, got %d", len(f.sent))
}
if f.sent[0].to != "[email protected]" {
t.Errorf("wrong recipient: %q", f.sent[0].to)
}
}
More lines? Yes, by about six. But:
I can see exactly what was sent and assert on it normally. There’s no string-based method matching. Refactoring the Emailer interface (renaming Send, changing the signature) immediately fails to compile, instead of silently passing a test that mocked the old name.
I don’t have a MockSomething parallel hierarchy that I have to keep in sync with my real interface. The fake is just another implementation.
The mock library does have one thing fakes don’t: built-in argument matching like mock.Anything, mock.AnythingOfType("string"). I’ve found that when I need that, my test is doing too much. The fix is usually to make my interface narrower, not to match against Anything.
suite: I gave up on this one
suite.Suite lets you define SetupTest and TearDownTest methods on a struct, with shared state, like JUnit. I used it for database integration tests for a year. Then I switched to plain test helpers and was happier.
The problem with suite.Run is that it hides what’s happening. New people on the team had to learn the suite lifecycle (which methods get called when, in what order, what SetupTest vs SetupSuite does) instead of just reading the test top to bottom. I had bugs where state leaked between subtests because I’d written SetupSuite instead of SetupTest and didn’t notice.
The plain Go version uses t.Cleanup and a helper:
func setupDB(t *testing.T) *sql.DB {
t.Helper()
db := openTestDB(t)
t.Cleanup(func() {
db.Exec("TRUNCATE users CASCADE")
db.Close()
})
return db
}
func TestCreateUser(t *testing.T) {
db := setupDB(t)
err := users.Create(db, "[email protected]")
if err != nil {
t.Fatal(err)
}
}
Each test sets up its own state. t.Cleanup runs in reverse order when the test ends, even if it failed or panicked. There’s no shared receiver. If two tests need the same setup, I extract a helper. If they need different setup, they don’t share one.
This isn’t an indictment of suite. If you’ve used JUnit-style frameworks for years and the suite mental model is comfortable, fine. But the plain-Go version is right there, and it doesn’t require remembering which lifecycle hook runs when.
What I actually reach for in 2026
For value comparisons that aren’t deeply nested, plain t.Errorf with a clear message. The diff is rarely worth the dependency.
For deeply nested struct comparisons, google/go-cmp. It’s by far the best diff output in Go and it has good options for ignoring unexported fields, comparing floats with tolerance, and so on:
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("response mismatch (-want +got):\n%s", diff)
}
For HTTP handlers, httptest.NewRecorder and reading rec.Code, rec.Body.String(). Faster than building a request mock, and exactly the API the standard library gives you.
For database integration tests, plain helpers and t.Cleanup, plus testing.Short() to skip them on CI fast-runs.
For cases where I’d otherwise reach for assert.Equal heavily across a single repo, I still pull in testify. The four lines vs one line trade-off matters when you have hundreds of assertions. I just don’t use mock or suite from it. Some thoughts on this kind of decision live in my Go error handling patterns post, since “what to do when something fails” applies to test code too.
Migrating off testify (or onto it)
If you’re considering moving off testify, don’t do it all at once. The pattern that worked for me:
Step one, stop adding new tests with mock and suite. Use fakes and helpers in any new code.
Step two, when you touch an old test that uses mocks, replace it with a fake. Don’t migrate just for the sake of migrating, but when you’re already in there, take the ten minutes.
Step three, leave assert and require in place. They’re fine. Removing them for ideological reasons just makes diffs noisier without making the code better.
If you’re considering moving onto testify, the decision is smaller than it feels. Add it. Use require for preconditions and assert for checks. Skip mock and suite until you have a specific reason to use them. If you never have that reason, you’ve spent zero time learning APIs you don’t use.
What to try this week
Pick one test file in your project that uses mocks heavily. Convert one mock to a fake (a struct that implements the same interface, with a slice that records calls). Run the test. See if you find the new version easier to understand. If yes, keep going. If no, the mock library is doing something for you, and you should figure out what.
If you don’t use testify yet, try this: write your next test with require.NoError for the precondition checks and assert.Equal for the actual checks. See if the failure messages are more useful than what you had before. If they are, you’ve justified the dependency. If they aren’t, the standard library was already doing fine.
You can see a few of the Go services I’ve shipped on my portfolio, and they’re a mix: some use testify, some don’t. I stopped picking sides about this years ago. Picking the right testing tool for a specific test matters a lot more than picking the right framework for the whole repo.