Master the iterator pattern for table-driven tests, understand when to use require vs assert, and learn testify best practices.
Essential patterns and anti-patterns for writing maintainable, effective Go tests.
What makes a good test?
RFC
This page holds quite a few opinions. Feel free to share your experience and thoughts and help contribute to this page!
A good test is:
Focused - Tests one logical concept
Independent - Can run in any order, in parallel
Repeatable - Same input always produces same result
Fast - Runs quickly to encourage frequent execution
Have clear expectations - Failure messages immediately show what broke
With testify, you write tests that read like documentation:
import (
"testing""github.com/go-openapi/testify/v2/assert""github.com/go-openapi/testify/v2/require")
funcTestUserCreation(t*testing.T) {
user:=CreateUser("alice@example.com")
require.NotNil(t, user)
assert.Equal(t, "alice@example.com", user.Email) // if user is nil, will fail and stop beforeassert.True(t, user.Active)
}
tip
Adopt a test layout similar to your functionality.
# ❌ Don't do this - confusingboolean.go
file.go
all_test.go
// ✅ Better - clear mapping between features and testsboolean.goboolean_test.gofile.gofile_test.go
The assertions are self-documenting - you can read the test and immediately understand what behavior is being verified.
Patterns
Simple test logic
Oftentimes, much of the test logic can be replaced by a proper use of require.
import (
"testing""github.com/go-openapi/testify/v2/assert")
// ❌ Don't do this - repetitive and hard to maintainfuncTestUserCreation(t*testing.T) {
user:=CreateUser("alice@example.com")
ifassert.NotNil(t, user) {
assert.Equal(t, "alice@example.com", user.Email) // if user is nil, will skip this testassert.True(t, user.Active)
}
}
import (
"testing""github.com/go-openapi/testify/v2/assert""github.com/go-openapi/testify/v2/require")
// ✅ Better - linear flow, no indented subcasesfuncTestUserCreation(t*testing.T) {
user:=CreateUser("alice@example.com")
require.NotNil(t, user)
assert.Equal(t, "alice@example.com", user.Email) // if user is nil, will fail and stop beforeassert.True(t, user.Active)
}
Table-Driven Tests with Iterator Pattern
The iterator pattern is a great and idiomatic way to write table-driven tests in Go 1.23+.
This repository uses it extensively, and we think you should too.
Why Table-Driven Tests?
This separates the (repeated) logic a test from the test cases, making it easier to add or modify test cases.
Each test case may be run in parallel. Typically, each subtest in the test loop is independent.
Instead of writing separate test functions for each case:
import (
"testing""github.com/go-openapi/testify/v2/assert")
// ❌ Don't do this - repetitive and hard to maintainfuncTestAdd_PositiveNumbers(t*testing.T) {
result:=Add(2, 3)
assert.Equal(t, 5, result)
}
funcTestAdd_NegativeNumbers(t*testing.T) {
result:=Add(-2, -3)
assert.Equal(t, -5, result)
}
funcTestAdd_MixedSigns(t*testing.T) {
result:=Add(-2, 3)
assert.Equal(t, 1, result)
}
Write one test function with multiple cases:
// ✅ Better - all cases in one placefuncTestAdd(t*testing.T) {
t.Parallel()
// All test cases defined once// Test logic written once// Easy to add new casesforc:=rangeaddTestCases() {
t.Run(c.name, func(t*testing.T) {
t.Parallel() // each iteration runs concurrentlyresult:=Add(c.a, c.b)
assert.Equal(t, c.expected, result)
})
}
}
funcaddTestCases() iter.Seq[addTestCase] {
...}
The Iterator Pattern
It values test cases as the main asset of your tests: by promoting testcases to their own
function and type, they become reusable and parameterizable.
In this project, we leverage testcase reusability a lot, for instance to make sure that both generic and non-generic assertions
are subject to the same tests.
Structure:
import (
"iter""slices""testing""github.com/go-openapi/testify/v2/assert")
// 1. Define a test case structtypeaddTestCasestruct {
namestring// the test case name is documented in the test execution logs and identifiable when failinga, bintexpectedint}
// 2. Create an iterator function returning iter.Seq[T]funcaddTestCases() iter.Seq[addTestCase] {
returnslices.Values([]addTestCase{
{
name: "positive numbers",
a: 2,
b: 3,
expected: 5,
},
{
name: "negative numbers",
a: -2,
b: -3,
expected: -5,
},
{
name: "mixed signs",
a: -2,
b: 3,
expected: 1,
},
{
name: "with zero",
a: 0,
b: 5,
expected: 5,
},
})
}
// 3. Test function iterates over cases using rangefuncTestAdd(t*testing.T) {
t.Parallel()
forc:=rangeaddTestCases() {
t.Run(c.name, func(t*testing.T) {
t.Parallel()
result:=Add(c.a, c.b)
assert.Equal(t, c.expected, result)
})
}
}
Why This Pattern Is Better
This is an opinionated pattern, based on our own experience with maintaining tens of thousands of tests.
You might hold a different opinion. Here are the reasons that support the proposed approach.
Clean separation of concerns:
Test data (in iterator function) separate from test logic (in test function)
Easy to see all test cases at a glance
Easy to add new cases without touching test logic
Type safety:
Compiler enforces struct fields
No risk of wrong number of arguments
IDE autocomplete works perfectly
Excellent for parallel execution:
Both the outer test and subtests can run in parallel
t.Parallel() catches race conditions early
Reusable:
Iterator functions can be reused across multiple test functions
Share test cases between related tests
Maintainable:
Adding a case: just append to the slice
Changing test logic: edit one place
Renaming fields: IDE refactoring works
Test cases can be reused, composed, parameterized
Comparison with Traditional Pattern
The proposed pattern is slightly more verbose than the inlined pattern,
but this is largely offset by the improved readability as soon as you get a few test cases.
When your test logic gets more complex, the reader’s focus is on what runs.
Traditional inline pattern:
import (
"testing""github.com/go-openapi/testify/v2/assert")
funcTestAdd(t*testing.T) {
tests:= []struct {
namestringa, bintexpectedint }{
{"positive", 2, 3, 5},
{"negative", -2, -3, -5},
// Test data mixed with test function// Hard to reuse// No named fields - order matters }
for_, tt:=rangetests {
t.Run(tt.name, func(t*testing.T) {
result:=Add(tt.a, tt.b)
assert.Equal(t, tt.expected, result)
})
}
}
Iterator pattern:
import (
"iter""slices""testing")
// Test logic separate and cleanfuncTestAdd(t*testing.T) {
t.Parallel()
forc:=rangeaddTestCases() { // Clean iteration// ... }
}
typeaddTestCasestruct {
namestringa, bintexpectedint}
// Test data in separate function - clean, reusablefuncaddTestCases() iter.Seq[addTestCase] {
returnslices.Values([]addTestCase{
{
name: "positive numbers", // Named fieldsa: 2, // Self-documentingb: 3,
expected: 5,
},
// More cases... })
}
When extracting common assertions into helper functions, use t.Helper() to get better error messages:
import (
"testing""github.com/go-openapi/testify/v2/assert")
funcassertUserValid(t*testing.T, user*User) {
t.Helper() // Makes test failures point to the callerassert.NotNil(t, user)
assert.NotEmpty(t, user.Name)
assert.NotEmpty(t, user.Email)
assert.Greater(t, user.Age, 0)
}
funcTestUserCreation(t*testing.T) {
user:=CreateUser("alice@example.com")
// If this fails, error points HERE, not inside assertUserValidassertUserValid(t, user)
}
Without t.Helper(), failures would show the line number inside assertUserValid, making it harder to find the actual failing test.
Parallel Test Execution
Always use t.Parallel() unless you have a specific reason not to:
import (
"testing""github.com/go-openapi/testify/v2/assert")
funcTestAdd(t*testing.T) {
t.Parallel() // Outer test runs in parallelforc:=rangeaddTestCases() {
t.Run(c.name, func(t*testing.T) {
t.Parallel() // Each subtest runs in parallelresult:=Add(c.a, c.b)
assert.Equal(t, c.expected, result)
})
}
}
Benefits:
Tests run faster
Catches race conditions and shared state bugs
Encourages writing independent tests
When NOT to use parallel:
Tests that modify global state
Tests that use the same external resource (file, database, etc.)
Integration tests with shared setup
Setup and Teardown
Use t.Cleanup for cleanup:
funcTestDatabaseOperations(t*testing.T) {
db:=setupTestDatabase(t)
t.Cleanup(func() {
_ = db.Close() // Always runs, even if test fails }
user:=&User{Name: "Alice"}
err:=db.Save(user)
require.NoError(t, err) // Stop if save failsloaded, err:=db.Find(user.ID)
require.NoError(t, err)
assert.Equal(t, "Alice", loaded.Name)
}
Pattern for resources:
Create resource
Immediately defer cleanup
Use the resource
Cleanup happens automatically
Edge Cases to Test
Remember to include limit cases to your tests. Always.
Besides the happy path, include these test categories: