Writing good tests is one of the clearest signs of a mature engineer. In Go, the standard library already gives us an excellent testing package, and the community has developed strong conventions around it.
One of the most important patterns is table-driven tests.
1. Why Table-Driven Tests?
Instead of writing many almost identical test functions, we define a slice of test cases and iterate over them. This approach brings several benefits:
- Less duplication
- Easier to add new cases
- Clear separation between input and expected output
- Better readability
2. Basic Table-Driven Test
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -1, -2},
{"zero", 0, 5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}
Using t.Run creates subtests, which gives better output and allows running individual cases.
3. Testing Errors
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
expectError bool
}{
{"valid division", 10, 2, 5, false},
{"divide by zero", 10, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)
if tt.expectError {
if err == nil {
t.Fatal("expected an error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
4. Useful Testing Helpers
t.Helper()
Mark helper functions so the line number in failures points to the caller:
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
t.Cleanup()
Register cleanup functions (useful for temporary files, database rows, etc.):
t.Cleanup(func() {
// clean up resources
})
5. Testing with Dependencies (Interfaces + Mocks)
Prefer depending on interfaces so you can inject fakes or mocks:
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
}
type Service struct {
repo UserRepository
}
func (s *Service) GetUserEmail(ctx context.Context, id int) (string, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return "", err
}
return user.Email, nil
}
In tests you can provide a simple fake implementation instead of hitting a real database.
6. Best Practices
Do
- Use table-driven tests for most logic.
- Give each test case a descriptive name.
- Use
t.Runfor subtests. - Test behavior, not implementation details.
- Keep tests focused and independent.
- Run tests with race detection:
go test -race ./...
Don’t
- Don’t write tests that are harder to understand than the code itself.
- Don’t ignore error returns in tests.
- Don’t rely on test execution order.
- Avoid excessive mocking when a simple fake would do.
7. Bonus: Golden Files / Snapshot Testing
For more complex outputs (JSON, large structures, generated code), consider comparing against a known good file (“golden file”). The standard library doesn’t include this, but libraries like github.com/sebdah/goldie or simple custom helpers work well.
Conclusion
Table-driven tests are one of the most effective patterns in Go. Combined with good use of interfaces, subtests, and the race detector, they help you build confidence in your code without sacrificing readability.
Key takeaways:
- Prefer table-driven tests
- Use subtests (
t.Run) - Keep test cases clear and focused
- Design your code to be testable (interfaces help)
Good tests are not just about coverage numbers — they are about giving you the confidence to change code safely.
Happy testing!