Generic Testing Helpers — Tasks¶
Exercise structure¶
- 🟢 Easy — for beginners
- 🟡 Medium — middle level
- 🔴 Hard — senior level
- 🟣 Expert — professional level
A solution for each exercise is provided at the end. Each task focuses on building a real test helper — not on testing some unrelated function. Always include t.Helper() and use stdlib pieces when available.
Easy 🟢¶
Task 1 — AssertEqual[T comparable]¶
Write the smallest useful generic equality helper with t.Helper() and an Errorf failure.
Task 2 — AssertNotEqual[T comparable]¶
Mirror Task 1 but fail when the values are equal.
Task 3 — AssertTrue and AssertFalse¶
Two helpers that take a bool and a *testing.T. Use Errorf and clear messages.
Task 4 — AssertNoError¶
Fail with t.Fatalf when err != nil. Why fatal?
Task 5 — AssertContains for strings¶
Write AssertContains(t *testing.T, s, sub string) that wraps strings.Contains.
Task 6 — AssertNil and AssertNotNil for pointers¶
Use [T any] plus *T parameters.
Medium 🟡¶
Task 7 — AssertSliceEqual[T comparable]¶
Wrap slices.Equal. Format the failure on two lines for readability.
Task 8 — AssertSliceContains[T comparable]¶
Use slices.Contains internally.
Task 9 — AssertMapEqual[K, V comparable]¶
Wrap maps.Equal.
Task 10 — AssertErrorIs¶
Use errors.Is. Failure message should print both the actual error and the target.
Task 11 — AssertErrorAs[T error]¶
Use errors.As and return the typed error so the caller can inspect it.
Task 12 — Generic table-driven runner¶
Write RunCases[I, O comparable](t *testing.T, cases []Case[I, O], fn func(I) O) that uses t.Run and Equal.
Task 13 — AssertSetEqual[T cmp.Ordered]¶
Compare two slices ignoring order. Sort copies; never mutate the caller's data.
Task 14 — Float helper with epsilon¶
Write AssertFloatNear(t, got, want, eps float64). Handle NaN explicitly.
Hard 🔴¶
Task 15 — AssertPanics[T any]¶
Catch a panic, ensure it has type T, and return the recovered value.
Task 16 — AssertEventually¶
Poll a predicate until it returns true or a timeout elapses. Signature: AssertEventually(t *testing.T, predicate func() bool, timeout, interval time.Duration). Why is this useful?
Task 17 — Generic fixture builder¶
Implement WithFixture[T any](t *testing.T, build func(*testing.T) (T, func())) T that registers cleanup with t.Cleanup.
Task 18 — AssertCmpEqual[T any] using go-cmp¶
Wrap cmp.Diff so the helper produces a -want +got diff on failure. Allow extra cmp.Option arguments.
Task 19 — Multiset comparison¶
Write AssertMultisetEqual[T comparable] that ignores order and accepts non-orderable types. Use a map of counts.
Expert 🟣¶
Task 20 — Test the testlib¶
Build a fake testing.TB (a struct that records calls to Errorf / Fatalf) and write tests that call Equal and assert on the recorded behaviour.
Task 21 — Generic subtest namer¶
Write a helper that generates subtest names from a struct: given a tc of type T, produce a name like "in=42_want=84". Use reflection or a Named interface.
Task 22 — Migrate a fake testify suite¶
Take a tiny testify-style file with assert.Equal, require.NoError, assert.ElementsMatch and migrate it to generic stdlib-shaped helpers. Discuss what changed.
Solutions¶
Solution 1¶
import "testing"
func AssertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
Solution 2¶
func AssertNotEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got == want {
t.Errorf("got %v, want != %v", got, want)
}
}
Solution 3¶
func AssertTrue(t *testing.T, cond bool) {
t.Helper()
if !cond { t.Errorf("expected true, got false") }
}
func AssertFalse(t *testing.T, cond bool) {
t.Helper()
if cond { t.Errorf("expected false, got true") }
}
Solution 4¶
func AssertNoError(t *testing.T, err error) {
t.Helper()
if err != nil { t.Fatalf("unexpected error: %v", err) }
}
Solution 5¶
import "strings"
func AssertContains(t *testing.T, s, sub string) {
t.Helper()
if !strings.Contains(s, sub) {
t.Errorf("string %q does not contain %q", s, sub)
}
}
Solution 6¶
func AssertNil[T any](t *testing.T, p *T) {
t.Helper()
if p != nil { t.Errorf("expected nil, got %v", *p) }
}
func AssertNotNil[T any](t *testing.T, p *T) {
t.Helper()
if p == nil { t.Errorf("expected non-nil pointer to %T", *new(T)) }
}
Solution 7¶
import (
"slices"
"testing"
)
func AssertSliceEqual[T comparable](t *testing.T, got, want []T) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("slice mismatch:\n got: %v\n want: %v", got, want)
}
}
Solution 8¶
func AssertSliceContains[T comparable](t *testing.T, s []T, target T) {
t.Helper()
if !slices.Contains(s, target) {
t.Errorf("slice %v does not contain %v", s, target)
}
}
Solution 9¶
import "maps"
func AssertMapEqual[K, V comparable](t *testing.T, got, want map[K]V) {
t.Helper()
if !maps.Equal(got, want) {
t.Errorf("map mismatch:\n got: %v\n want: %v", got, want)
}
}
Solution 10¶
import "errors"
func AssertErrorIs(t *testing.T, err, target error) {
t.Helper()
if !errors.Is(err, target) {
t.Fatalf("error chain does not contain %v: got %v", target, err)
}
}
Solution 11¶
func AssertErrorAs[T error](t *testing.T, err error) T {
t.Helper()
var target T
if !errors.As(err, &target) {
t.Fatalf("expected error of type %T, got %T (%v)", target, err, err)
}
return target
}
Solution 12¶
type Case[I, O any] struct {
Name string
In I
Want O
}
func RunCases[I, O comparable](t *testing.T, cases []Case[I, O], fn func(I) O) {
t.Helper()
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
AssertEqual(t, fn(tc.In), tc.Want)
})
}
}
Solution 13¶
import (
"cmp"
"slices"
)
func AssertSetEqual[T cmp.Ordered](t *testing.T, got, want []T) {
t.Helper()
g := slices.Clone(got)
w := slices.Clone(want)
slices.Sort(g)
slices.Sort(w)
if !slices.Equal(g, w) {
t.Errorf("set mismatch:\n got: %v\n want: %v", got, want)
}
}
Solution 14¶
import "math"
func AssertFloatNear(t *testing.T, got, want, eps float64) {
t.Helper()
if math.IsNaN(got) && math.IsNaN(want) { return }
if math.Abs(got-want) > eps {
t.Errorf("got %v, want %v ± %v", got, want, eps)
}
}
Solution 15¶
func AssertPanics[T any](t *testing.T, fn func()) (got T, ok bool) {
t.Helper()
defer func() {
r := recover()
if r == nil {
t.Fatalf("expected panic, got none")
return
}
v, isT := r.(T)
if !isT {
t.Fatalf("recovered non-T value: %v (%T)", r, r)
return
}
got = v
ok = true
}()
fn()
return
}
Solution 16¶
import "time"
func AssertEventually(t *testing.T, predicate func() bool, timeout, interval time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if predicate() { return }
time.Sleep(interval)
}
t.Fatalf("predicate did not become true within %v", timeout)
}
Solution 17¶
func WithFixture[T any](t *testing.T, build func(*testing.T) (T, func())) T {
t.Helper()
v, clean := build(t)
t.Cleanup(clean)
return v
}
Solution 18¶
import "github.com/google/go-cmp/cmp"
func AssertCmpEqual[T any](t *testing.T, got, want T, opts ...cmp.Option) {
t.Helper()
if d := cmp.Diff(want, got, opts...); d != "" {
t.Errorf("mismatch (-want +got):\n%s", d)
}
}
Solution 19¶
func AssertMultisetEqual[T comparable](t *testing.T, got, want []T) {
t.Helper()
countOf := func(s []T) map[T]int {
m := map[T]int{}
for _, v := range s { m[v]++ }
return m
}
if !maps.Equal(countOf(got), countOf(want)) {
t.Errorf("multiset mismatch:\n got: %v\n want: %v", got, want)
}
}
Solution 20¶
type fakeTB struct {
testing.TB
errors []string
fatals []string
}
func (f *fakeTB) Helper() {}
func (f *fakeTB) Errorf(format string, args ...any) {
f.errors = append(f.errors, fmt.Sprintf(format, args...))
}
func (f *fakeTB) Fatalf(format string, args ...any) {
f.fatals = append(f.fatals, fmt.Sprintf(format, args...))
runtime.Goexit()
}
testing.TB has an unexported method, so embedding it lets us "satisfy" the interface for type-checking. Run the helper under test in a goroutine and use channels to signal completion. Solution 21¶
type Named interface{ Name() string }
func RunCasesNamed[T Named, O any](
t *testing.T,
cases []T,
fn func(*testing.T, T),
) {
t.Helper()
for _, tc := range cases {
t.Run(tc.Name(), func(t *testing.T) { fn(t, tc) })
}
}
Name() string. Cleaner than reflection for most projects. Solution 22¶
// Before
func TestUser_Old(t *testing.T) {
u, err := loadUser(1)
require.NoError(t, err)
assert.Equal(t, "alice", u.Name)
assert.ElementsMatch(t, u.Roles, []string{"admin", "user"})
}
// After
func TestUser_New(t *testing.T) {
u, err := loadUser(1)
AssertNoError(t, err)
AssertEqual(t, u.Name, "alice")
AssertSetEqual(t, u.Roles, []string{"admin", "user"})
}
(got, want) order is consistent, no third-party dependency. Final notes¶
These tasks deliberately mirror the helpers shipped by mature Go projects. The real lesson is comparison: every helper you write should be paired in your mind with the inline check it replaces. The point is not the new syntax; it is the discipline of t.Helper(), clear messages, and small APIs.
A complete in-house testlib usually contains 12-20 helpers. Anything bigger should make you reach for testify or gotest.tools instead of building your own.