Go Testing: Unit Tests, Benchmarks, and Table-Driven Tests
Testing is a first-class concern in Go. The language ships with a built-in testing package, a test runner (go test), benchmarking support, and tools for code coverage and fuzzing — all without third-party dependencies. This integrated approach means there is no excuse not to test, and the Go community has developed conventions that make tests consistent, readable, and effective.
This guide covers everything from basic unit tests to advanced patterns like table-driven tests, mocking, benchmarks, and test coverage analysis.
Basic Unit Tests
Go test files follow naming conventions: files end with _test.go, and test functions start with Test and take a *testing.T parameter.
// math.go
package math
func Add(a, b int) int {
return a + b
---
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil
---// math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
---
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %d; want 5", result)
}
---
func TestDivideByZero(t *testing.T) {
_, err := Divide(1, 0)
if err == nil {
t.Error("expected error for division by zero")
}
---# Run tests
go test ./...
go test -v ./... # Verbose output
go test -run TestAdd # Run specific testTable-Driven Tests
Table-driven tests are the idiomatic Go way to test multiple scenarios with minimal code duplication.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{name: "positive numbers", a: 2, b: 3, expected: 5},
{name: "negative numbers", a: -1, b: -2, expected: -3},
{name: "zero values", a: 0, b: 0, expected: 0},
{name: "mixed signs", a: -5, b: 8, expected: 3},
}
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)
}
})
}
---Subtests with Shared Setup
func TestDatabase(t *testing.T) {
db := setupTestDatabase(t) // shared setup
defer db.Close()
t.Run("insert", func(t *testing.T) {
err := db.Insert("key", "value")
if err != nil {
t.Fatal(err)
}
})
t.Run("query", func(t *testing.T) {
val, err := db.Query("key")
if err != nil {
t.Fatal(err)
}
if val != "value" {
t.Errorf("got %q, want %q", val, "value")
}
})
---Benchmarking
Go includes built-in benchmarking. Benchmark functions start with Benchmark and take a *testing.B.
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(100, 200)
}
---
func BenchmarkStringConcat(b *testing.B) {
a, bStr := "Hello", "World"
for i := 0; i < b.N; i++ {
_ = a + " " + bStr
}
---
func BenchmarkStringBuilder(b *testing.B) {
a, bStr := "Hello", "World"
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString(a)
sb.WriteString(" ")
sb.WriteString(bStr)
_ = sb.String()
}
---go test -bench=. ./...
go test -bench=. -benchmem ./... # Include memory allocation statsTest Coverage
# Run tests with coverage
go test -cover ./...
# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View coverage in browser
go cover -html=coverage.out
# Show coverage per function
go tool cover -func=coverage.outCoverage-Focused Testing
// Ensure edge cases are covered
func TestAddEdgeCases(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"max int", math.MaxInt64, 0, math.MaxInt64},
{"overflow", math.MaxInt64, 1, math.MinInt64}, // Go wraps around
{"min int", math.MinInt64, 1, math.MinInt64 + 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add() = %v, want %v", got, tt.want)
}
})
}
---Mocking
Go interfaces make mocking straightforward. Create mock implementations that satisfy the interface.
// user.go
type UserStore interface {
GetUser(id int) (*User, error)
CreateUser(u *User) error
---
type UserService struct {
store UserStore
---
func (s *UserService) GetUserName(id int) (string, error) {
user, err := s.store.GetUser(id)
if err != nil {
return "", err
}
return user.Name, nil
---// user_test.go
type mockUserStore struct {
users map[int]*User
err error
---
func (m *mockUserStore) GetUser(id int) (*User, error) {
if m.err != nil {
return nil, m.err
}
user, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("user not found")
}
return user, nil
---
func (m *mockUserStore) CreateUser(u *User) error {
return m.err
---
func TestGetUserName(t *testing.T) {
tests := []struct {
name string
id int
store *mockUserStore
want string
wantErr bool
}{
{
name: "success",
id: 1,
store: &mockUserStore{
users: map[int]*User{1: {ID: 1, Name: "Alice"}},
},
want: "Alice",
},
{
name: "store error",
id: 1,
store: &mockUserStore{err: fmt.Errorf("db down")},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &UserService{store: tt.store}
got, err := svc.GetUserName(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("GetUserName() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetUserName() = %q, want %q", got, tt.want)
}
})
}
---Test Helpers
// helper_test.go
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper() // Marks this as a helper → line numbers point to caller
if got != want {
t.Errorf("got %v, want %v", got, want)
}
---
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
---
func TestWithHelpers(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5)
_, err := Divide(10, 2)
assertNoError(t, err)
---Test Main and Setup
For integration tests that need setup and teardown, use TestMain:
func TestMain(m *testing.M) {
// Setup
db = setupTestDatabase()
code := m.Run()
// Teardown
db.Close()
os.Exit(code)
---TestMain is called instead of running tests directly. It gives you a single place for shared setup and teardown. Use it sparingly — most tests should use t.Run subtests with their own setup.
Fuzz Testing
Go 1.18+ includes built-in fuzz testing:
func FuzzParse(f *testing.F) {
f.Add("valid input")
f.Add("")
f.Add("special!@#chars")
f.Fuzz(func(t *testing.T, input string) {
result, err := Parse(input)
if err != nil {
return // Expected for some inputs
}
if result == "" {
t.Errorf("Parse(%q) returned empty result with no error", input)
}
})
---Run fuzz tests with go test -fuzz=.. The fuzzer generates random inputs and reports any that cause panics or test failures.
Test Coverage and CI
Track coverage and enforce minimums in CI:
# GitHub Actions step
- run: go test -race -coverprofile=coverage.out ./...
- run: go tool cover -func=coverage.out
# Fail if coverage below threshold
- run: |
coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $NF}' | tr -d '%')
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "Coverage $coverage% is below 80% threshold"
exit 1
fiCombine -race and -cover flags in CI to catch both race conditions and untested code in every run.
Best Practices
- Test behavior, not implementation — Tests should pass after refactoring if the behavior is unchanged.
- Use
t.Helper()in test helpers — This makes failure line numbers point to the caller, not the helper. - Avoid
_ = fn()without explanation — If ignoring an error, document why. - Run tests in parallel when safe — Use
t.Parallel()for independent tests to speed up CI. - Test error paths — Every function that returns an error should have tests for the error case.
- Use
-raceflag — Run tests with the race detector enabled in CI.
Conclusion
Go’s built-in testing tools make writing tests fast and consistent. Use table-driven tests to cover multiple scenarios, benchmarks to measure performance, and interfaces for clean mocking. Run go test -cover regularly to track coverage, and always test edge cases and error paths.
Related: Check our Go Error Handling guide.
FAQ
Q: What is a table-driven test? A: A test that uses a slice of test cases (a table) to test multiple scenarios with the same test logic. It is the idiomatic Go testing pattern.
Q: How do I run a single test?
A: Use go test -run TestName. The -run flag takes a regex matching test function names.
Q: What is the difference between t.Error and t.Fatal?
A: t.Error marks the test as failed but continues execution. t.Fatal marks the test as failed and stops immediately.
Q: How do I test code that uses environment variables?
A: Use t.Setenv(key, value) in Go 1.17+ to set environment variables for the duration of a test. The environment is restored automatically.
Q: What is the race detector and how do I use it?
A: The race detector (go test -race) instruments your program to detect concurrent memory access conflicts. Run it regularly in CI.
Q: How do I measure test coverage?
A: Use go test -coverprofile=coverage.out and go tool cover -html=coverage.out to view coverage in your browser.
For a comprehensive overview, read our article on Getting Started With Go.