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 equalityAsserting errorsOther assertionsFinal 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.