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:
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.
// ❌ 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)
}
}
// ✅ 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 the idiomatic way to write table-driven tests in Go 1.23+. This repository uses it extensively, and you should too.
Why Table-Driven Tests?
Instead of writing separate test functions for each case:
// ❌ 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) {
// 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()
result:=Add(c.a, c.b)
assert.Equal(t, c.expected, result)
})
}
}
funcaddTestCases() iter.Seq[addTestCase] {
...}
When extracting common assertions into helper functions, use t.Helper() to get better error messages:
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:
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 defer 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)
}