Expressive tests without testify/assert
Many Go programmers prefer using if-free test assertions to make their tests shorter and easier to read. So, instead of writing if statements with t.Errorf
:
func Test(t *testing.T) {
db := getDB(t)
db.Str().Set("name", "alice")
age, err := db.Str().Get("age")
if !errors.Is(err, redka.ErrNotFound) {
t.Errorf("want ErrNotFound, got %v", err)
}
if age != nil {
t.Errorf("want nil, got %v", age)
}
}
PASS
They would use testify/assert
(or its evil twin, testify/require
):
func Test(t *testing.T) {
db := getDB(t)
db.Str().Set("name", "alice")
age, err := db.Str().Get("age")
assert.ErrorIs(t, err, redka.ErrNotFound)
assert.Nil(t, age)
}
However, I don't think you need testify/assert
and its 40 different assertion functions to keep your tests clean. Here's an alternative approach.
The testify package also provides mocks and test suite helpers. We won't talk about these — just about assertions.
Asserting equality • Asserting errors • Other assertions • Final thoughts
Asserting equality
The most common type of test assertion is checking for equality:
func Test(t *testing.T) {
db := getDB(t)
db.Str().Set("name", "alice")
name, _ := db.Str().Get("name") // skip error checking for now
if name.String() != "alice" {
t.Errorf("want 'alice', got '%v'", name)
}
}
PASS
Let's write a basic generic assertion helper:
// AssertEqual asserts that got is equal to want.
func AssertEqual[T any](tb testing.TB, got T, want T) {
tb.Helper()
// Check if both are nil.
if isNil(got) && isNil(want) {
return
}
// Fallback to reflective comparison.
if reflect.DeepEqual(got, want) {
return
}
// No match, report the failure.
tb.Errorf("want %#v, got %#v", want, got)
}
We have to use a helper isNil
function, because the compiler doesn't allow us to compare a typed T
value with an untyped nil
:
// isNil checks if v is nil.
func isNil(v any) bool {
if v == nil {
return true
}
// A non-nil interface can still hold a nil value,
// so we must check the underlying value.
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface,
reflect.Map, reflect.Pointer, reflect.Slice,
reflect.UnsafePointer:
return rv.IsNil()
default:
return false
}
}
Now let's use the assertion in our test:
func Test(t *testing.T) {
db := getDB(t)
db.Str().Set("name", "alice")
name, _ := db.Str().Get("name")
AssertEqual(t, name.String(), "alice")
}
PASS
The parameter order in AssertEqual
is (got, want), not (want, got) like it is in testify. It just feels more natural — saying "her name is Alice" instead of "Alice is her name".
Also, unlike testify, our assertion doesn't support custom error messages. When a test fails, you'll end up checking the code anyway, so why bother? The default error message shows what's different, and the line number points to the rest.
AssertEqual
is already good enough for all equality checks, which probably make up to 70% of your test assertions. Not bad for a 20-line testify alternative! But we can make it a little better, so let's not miss this chance.
First, types like time.Time
and net.IP
have an Equal
method. We should use this method to make sure the comparison is accurate:
// equaler is an interface for types with an Equal method
// (like time.Time or net.IP).
type equaler[T any] interface {
Equal(T) bool
}
// areEqual checks if a and b are equal.
func areEqual[T any](a, b T) bool {
// Check if both are nil.
if isNil(a) && isNil(b) {
return true
}
// Try to compare using an Equal method.
if eq, ok := any(a).(equaler[T]); ok {
return eq.Equal(b)
}
// Fallback to reflective comparison.
return reflect.DeepEqual(a, b)
}
Second, we can make comparing byte slices faster by using bytes.Equal
:
// areEqual checks if a and b are equal.
func areEqual[T any](a, b T) bool {
// ...
// Special case for byte slices.
if aBytes, ok := any(a).([]byte); ok {
bBytes := any(b).([]byte)
return bytes.Equal(aBytes, bBytes)
}
// ...
}
Finally, let's call areEqual
from our AssertEqual
function:
// AssertEqual asserts that got is equal to want.
func AssertEqual[T any](tb testing.TB, got T, want T) {
tb.Helper()
if areEqual(got, want) {
return
}
tb.Errorf("want %#v, got %#v", want, got)
}
And test it on some values:
func Test(t *testing.T) {
// date1 and date2 represent the same point in time,
date1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
date2 := time.Date(2025, 1, 1, 5, 0, 0, 0, time.FixedZone("UTC+5", 5*3600))
AssertEqual(t, date1, date2) // ok
// b1 and b2 are equal byte slices.
b1 := []byte("abc")
b2 := []byte{97, 98, 99}
AssertEqual(t, b1, b2) // ok
// m1 and m2 are different maps.
m1 := map[string]int{"age": 25}
m2 := map[string]int{"age": 42} // change to 25 to pass
AssertEqual(t, m1, m2) // fail
}
FAIL: Test (0.00s)
main_test.go:47: want map[string]int{"age":42}, got map[string]int{"age":25}
Works like a charm!
Asserting errors
Errors are everywhere in Go, so checking for them is an important part of testing:
func Test(t *testing.T) {
db := getDB(t)
db.Str().Set("name", "alice")
_, err := db.Str().Get("name") // ignore the value for brevity
if err != nil {
t.Errorf("unexpected error: %v'", err)
}
}
PASS
Error checks probably make up to 30% of your test assertions, so let's create a separate function for them.
First we cover the basic cases — expecting no error and expecting an error:
// AssertErr asserts that the got error matches the want.
func AssertErr(tb testing.TB, got error, want error) {
tb.Helper()
// Want nil error, but got is not nil.
// This is a fatal error, so we fail the test immediately.
if want == nil && got != nil {
tb.Fatalf("unexpected error: %v", got)
return
}
// Want non-nil error, got nil.
if want != nil && got == nil {
tb.Errorf("want error, got <nil>")
return
}
// Leave the rest for later.
return
}
Usually we don't fail the test when an assertion fails, to see all the errors at once instead of hunting them one by one. The "unexpected error" case (want nil, got non-nil) is the only exception: the test terminates immediately because any following assertions probably won't make sense and could cause panics.
Let's see how the assertion works:
func Test(t *testing.T) {
db := getDB(t)
db.Str().Set("name", "alice")
_, err := db.Str().Get("name")
AssertErr(t, err, nil)
}
PASS
So far, so good. Now let's cover the rest of error checking without introducing separate functions (ErrorIs, ErrorAs, ErrorContains, etc.) like testify does.
If want
is an error, we'll use errors.Is
to check if the error matches the expected value:
// AssertErr asserts that the got error matches the want.
func AssertErr(tb testing.TB, got error, want any) {
tb.Helper()
if want != nil && got == nil {
tb.Error("want error, got <nil>")
return
}
switch w := want.(type) {
case nil:
if got != nil {
tb.Fatalf("unexpected error: %v", got)
}
case error:
if !errors.Is(got, w) {
tb.Errorf("want %T(%v), got %T(%v)", w, w, got, got)
}
default:
tb.Errorf("unsupported want type: %T", want)
}
}
Usage example:
func Test(t *testing.T) {
err := &fs.PathError{
Op: "open",
Path: "file.txt",
Err: fs.ErrNotExist,
}
AssertErr(t, err, fs.ErrNotExist)
}
PASS
If want
is a string, we'll check that the error message contains the expected substring:
// AssertErr asserts that the got error matches the want.
func AssertErr(tb testing.TB, got error, want any) {
// ...
switch w := want.(type) {
case string:
if !strings.Contains(got.Error(), w) {
tb.Errorf("want %q, got %q", w, got.Error())
}
//...
}
}
Usage example:
func Test(t *testing.T) {
_, err := regexp.Compile("he(?o") // invalid
AssertErr(t, err, "invalid or unsupported")
}
PASS
Finally, if want
is a type, we'll use errors.As
to check if the error matches the expected type:
// AssertErr asserts that the got error matches the want.
func AssertErr(tb testing.TB, got error, want any) {
// ...
switch w := want.(type) {
case reflect.Type:
target := reflect.New(w).Interface()
if !errors.As(got, target) {
tb.Errorf("want %s, got %T", w, got)
}
//...
}
}
Usage example:
func Test(t *testing.T) {
got := &fs.PathError{
Op: "open",
Path: "file.txt",
Err: fs.ErrNotExist,
}
var want *fs.PathError
AssertErr(t, got, reflect.TypeOf(want))
// Same thing.
AssertErr(t, got, reflect.TypeFor[*fs.PathError]())
}
PASS
One last thing: AssertErr
doesn't make it easy to check if there was some (non-nil) error without asserting its type or value (like Error
in testify). Let's fix this by making the want
parameter optional:
// AssertErr asserts that the got error matches any of the wanted values.
func AssertErr(tb testing.TB, got error, wants ...any) {
tb.Helper()
// If no wants are given, we expect got to be a non-nil error.
if len(wants) == 0 {
if got == nil {
tb.Error("want error, got <nil>")
}
return
}
// Here we only match against the first want for simplicity.
// Alternatively, we can (and probably should) check
// if got matches *any* of the wants.
want := wants[0]
// ...
}
Usage example:
func Test(t *testing.T) {
_, err := regexp.Compile("he(?o") // invalid
AssertErr(t, err) // want non-nil error
}
PASS
Now AssertErr
handles all the cases we need:
- Check if there is an error.
- Check if there is no error.
- Check for a specific error value.
- Check for an error of a certain type.
- Check if the error message matches what we expect.
And it's still under 40 lines of code. Not bad, right?
Other assertions
AssertEqual
and AssertErr
probably handle 85-95% of test assertions in a typical Go project. But there's still that tricky 5-15% left.
We may need to check for conditions like these:
func Test(t *testing.T) {
s := "go is awesome"
if len(s) < 5 {
t.Error("too short")
}
if !strings.Contains(s, "go") {
t.Error("too weak")
}
}
PASS
Technically, we can use AssertEqual
. But it looks a bit ugly:
func Test(t *testing.T) {
s := "go is awesome"
AssertEqual(t, len(s) >= 5, true)
AssertEqual(t, strings.Contains(s, "go"), true)
}
PASS
So let's introduce the third and final assertion function — AssertTrue
. It's the simplest one of all:
// AssertTrue asserts that got is true.
func AssertTrue(tb testing.TB, got bool) {
tb.Helper()
if !got {
tb.Error("not true")
}
}
Now these assertions look better:
func Test(t *testing.T) {
s := "go is awesome"
AssertTrue(t, len(s) >= 5)
AssertTrue(t, strings.Contains(s, "go"))
}
PASS
Nice!
Final thoughts
I don't think we need forty assertion functions to test Go apps. Three (or even two) are enough, as long as they correctly check for equality and handle different error cases.
I find the "assertion trio" — Equal, Err, and True — quite useful in practice. That's why I extracted it into the github.com/nalgeon/be mini-package. If you like the approach described in this article, give it a try!
★ Subscribe to keep up with new posts.