Skip to content
gohacks.dev
Testing

Remove Testify From a Project and Use the Standard Library

Remove testify from a Go project in steps: map assert and require calls to if statements, keep go-cmp for diffs, convert mocks to fakes and drop the module.

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

Testify is a fine library and not a necessary one. Teams remove it for three reasons: they want fewer dependencies in the code everyone reads, they prefer failure messages they write themselves, or they have found that the assertion vocabulary is heavier than the checks need. If you are in that position, the migration is mechanical and can be done a package at a time. This article gives the mapping and the order of work, with a converted test that runs on Go 1.25.6.

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

The migration of suites has its own article, moving off testify suites. This one covers assertions and mocks.

One test, converted

conv_test.goGo
package main

import (
	"errors"
	"testing"
)

var ErrNotFound = errors.New("not found")

func Find(id int) (string, error) {
	if id == 1 {
		return "ada", nil
	}
	return "", ErrNotFound
}

// The same checks as a testify test would make, written with the standard library.
func TestFindStdlib(t *testing.T) {
	got, err := Find(1) // require.NoError
	if err != nil {
		t.Fatalf("Find(1) error: %v", err)
	}
	if got != "ada" { // assert.Equal
		t.Errorf("Find(1) = %q, want %q", got, "ada")
	}
	_, err = Find(2)
	if !errors.Is(err, ErrNotFound) { // assert.ErrorIs
		t.Errorf("Find(2) error = %v, want ErrNotFound", err)
	}
	list := []string{"a", "b"} // assert.Len, assert.Contains
	if len(list) != 2 {
		t.Errorf("len = %d, want 2", len(list))
	}
}

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
--- PASS: TestFindStdlib

The test above is the standard library form of this testify test:

Go
got, err := Find(1)
require.NoError(t, err)
assert.Equal(t, "ada", got)
_, err = Find(2)
assert.ErrorIs(t, err, ErrNotFound)
list := []string{"a", "b"}
assert.Len(t, list, 2)
assert.Contains(t, list, "a")

Each testify call is a short if. The comments in the lab file mark the correspondence. The run passes, so the checks are equivalent for the cases written.

The mapping

TestifyStandard library
require.NoError(t, err)if err != nil { t.Fatalf("F(...) error: %v", err) }
assert.NoError(t, err)if err != nil { t.Errorf(...) }
assert.Error(t, err)if err == nil { t.Error("want an error") }
assert.ErrorIs(t, err, X)if !errors.Is(err, X) { t.Errorf(...) }
assert.ErrorAs(t, err, &T)if !errors.As(err, &target) { t.Errorf(...) }
assert.Equal(t, want, got)if got != want { t.Errorf("F() = %v, want %v", got, want) } for comparable types
assert.Equal on slicesslices.Equal(got, want)
assert.Equal on mapsmaps.Equal(got, want)
assert.Equal on structs with slices or mapsreflect.DeepEqual, or cmp.Diff from go-cmp
assert.ElementsMatch(t, a, b)Sort copies with slices.Sort, then slices.Equal
assert.Len(t, s, n)if len(s) != n { t.Errorf(...) }
assert.Contains(t, s, x)slices.Contains(s, x) or strings.Contains
assert.Empty(t, s)if len(s) != 0 { ... }
assert.Nil(t, p)if p != nil { ... }
assert.True(t, c)if !c { t.Error(...) }
assert.InDelta(t, a, b, d)if math.Abs(a-b) > d { ... }
assert.JSONEq(t, a, b)Unmarshal both into any, then reflect.DeepEqual
assert.Panics(t, f)A helper using defer and recover
assert.EventuallyA polling loop, or synctest
assert.WithinDurationif d := a.Sub(b).Abs(); d > delta { ... }
mock.MockA hand-written fake
suite.SuiteTestMain, helpers and t.Cleanup

What you give up

Failure messages. You write them, and that is mostly a gain: "Find(%d) = %q, want %q" names the function and the input, which testify's generic message does not. The loss is the automatic diff. For structures, add go-cmp: a short if on cmp.Diff that calls t.Errorf with the diff. The diff is better than testify's. That adds a dependency, a smaller and more focused one.

Brevity. assert.Equal(t, want, got) is shorter than the if. For a test with many checks on one result, a helper like func eq[T comparable](t *testing.T, got, want T) with t.Helper() restores the one-line form for scalar values without a library.

Convenience matchers. Contains, Subset, ElementsMatch, InDelta and JSONEq have no one-line equivalents. The standard library has slices.Contains, strings.Contains and slices.Sort, which cover most of them in two lines.

Mocks. Generated or testify mocks become fakes: small structs with the methods the test needs, recording what they were called with. See fakes, stubs and mocks.

What you gain

  • go vet checks your format strings. A mistake in t.Errorf("%d", "x") is reported at vet time. Testify's message arguments are checked by nothing.
  • Compile-time types. got != want requires matching types; assert.Equal(t, 1, int64(1)) compiles and fails.
  • One fewer module in go.mod and its transitive dependencies in go.sum, and a smaller surface to audit.
  • No FailNow in the wrong goroutine. The calls are visibly t.Fatal, and go vet flags the goroutine misuse.
  • Messages that name the case. Because you write them, they say what you want to know at 3 a.m.

An order of work

  1. Count. grep -rl "stretchr/testify" --include=*.go . | wc -l shows the size of the job.
  2. Convert mocks and suites first if you have them, since they have the most structure.
  3. Convert assertions package by package. Start with the simplest tests, where NoError, Equal and Len dominate.
  4. Introduce small helpers for the repeated shapes, one generic equal and one noError, in an internal test package; see shared helpers.
  5. Add go-cmp if structured comparisons need diffs.
  6. Run the suite with -race -shuffle=on after each package; a conversion can change behaviour where a testify call tolerated an unexpected type.
  7. Delete the dependency with go mod tidy once the last import is gone, and confirm with go list -m all | grep testify.

A semi-automatic approach is to run a regular expression replacement for the simplest forms and review the diff by eye. For anything more, edit by hand: the point of the migration is the messages, and they need a person.

Writing the small helpers

Two helpers cover most of what people miss after the removal. Put them in an internal test package, as in shared test helpers, with t.Helper() as the first statement of each.

  • equal[T comparable](t, got, want) fails with got X, want Y and works for numbers, strings, booleans and comparable structs. It restores the one-line form for scalar checks.
  • noError(t, err) calls t.Fatalf with the error text. It is a one-line guard like require.NoError.

Resist adding a helper per assertion you lost. Each helper hides a comparison, and a library of thirty of them is a smaller testify with fewer features and no documentation. Add one when a check appears in twenty tests, not when it appears in two.

For checks on collections, the slices and maps packages (Go 1.21 and later) give you Equal, Contains, Sort, Index and Compact, enough for most conversions. For deep structures, reflect.DeepEqual answers "equal or not" and cmp.Diff explains the difference. If the project already depends on go-cmp for other reasons, that decides it.

Verifying the conversion

A mechanical rewrite can change what a test checks without anyone noticing, so verify. Before the conversion, record the list of passing test names with go test -v ./... | grep -- '--- PASS' | sort > before.txt; after it, produce after.txt the same way and diff. Then break the code on purpose in two or three places and confirm the converted tests fail, ideally with a mutation tool as in the mutation testing article. A converted assertion that is wrong in a way that always passes, for instance a missing ! in an if, is the typical error of a hand conversion, and the deliberate break finds it.

When to stop short

A mixed state is acceptable. Many projects keep testify for assertions on JSON and collections, where it is convenient, and remove suites and mocks. If the team likes the vocabulary and the failure output, the cost of converting thousands of calls may not repay. The case for removal is strongest in libraries, whose test dependencies show up in users' module graphs, and in small, security-sensitive services where every dependency is reviewed. The comparison of approaches is in the libraries roundup and in the stdlib article. The standard library pieces are documented in the testing, errors and slices packages.

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.