Testing in Go
Write tests with the testing package, use table-driven tests and subtests, run benchmarks, and use testify for assertions.
Writing Your First Test
Go’s testing philosophy is that tests are just Go code — no test frameworks or special annotations required. The testing package provides a simple, direct API: receive a *testing.T, call t.Errorf to record a failure, and the test runner handles the rest. Test files end in _test.go and are excluded from production builds automatically, so tests live right next to the code they test without polluting the binary.
// math.go
package math
func Add(a, b int) int { return a + b }
func Sub(a, b int) int { return a - b }
// math_test.go — same package, so it can access unexported identifiers too
package math
import "testing"
func TestAdd(t *testing.T) {
result := Add(3, 4)
if result != 7 {
// t.Errorf marks the test as failed but continues running
t.Errorf("Add(3, 4) = %d; want 7", result)
}
}
func TestSub(t *testing.T) {
got := Sub(10, 3)
if got != 7 {
// t.Fatalf marks the test as failed and stops immediately
t.Fatalf("Sub(10, 3) = %d; want 7", got)
}
}
go test ./... # run all tests in all packages
go test -v ./... # verbose: show each test name and PASS/FAIL
go test -run TestAdd # run only tests whose name matches the regex "TestAdd"
go test -count=1 ./... # disable the test result cache — always re-run
Table-Driven Tests
Repeating nearly identical test functions for each edge case is tedious and error-prone. Table-driven tests solve this by defining all test cases in a slice and looping over them with a single test function body. Adding a new case is one line in the table — no new function, no duplicated setup. This is the idiomatic Go pattern for testing pure functions, and the standard library uses it extensively.
func TestAdd(t *testing.T) {
// Each entry in the table is one test scenario
tests := []struct {
name string
a, b int
want int
}{
{"positive", 3, 4, 7},
{"negative", -3, -4, -7},
{"zero", 0, 5, 5},
{"mixed", -3, 5, 2},
}
for _, tt := range tests {
// t.Run creates a subtest — failures are attributed to the specific case by name
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
go test -v -run TestAdd
# --- PASS: TestAdd (0.00s)
# --- PASS: TestAdd/positive (0.00s)
# --- PASS: TestAdd/negative (0.00s)
# --- PASS: TestAdd/zero (0.00s)
# --- PASS: TestAdd/mixed (0.00s)
Subtests
t.Run does more than enable table-driven tests — it creates a named subtest that shows up individually in the test output, can be targeted by name with -run, and runs its own t.Cleanup and t.Parallel scope. This makes large test functions readable: instead of one long failure message, you see exactly which case failed and what the expected vs actual values were.
func TestHTTPHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
wantStatus int
}{
{"get users", "GET", "/users", 200},
{"create user", "POST", "/users", 201},
{"not found", "GET", "/nonexistent", 404},
{"method not allowed", "DELETE", "/users", 405},
}
handler := setupHandler() // shared setup — called once before all subtests
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d; want %d", rec.Code, tt.wantStatus)
}
})
}
}
Testing HTTP Handlers
The net/http/httptest package lets you test HTTP handlers without starting a real server. httptest.NewRequest creates a synthetic request, and httptest.NewRecorder captures the response. This makes handler tests fast, deterministic, and runnable without any network configuration. You can inspect the status code, headers, and body just like you would with a real HTTP response.
import (
"net/http"
"net/http/httptest"
"testing"
"encoding/json"
)
func TestGetUserHandler(t *testing.T) {
// Build a fake request — no network needed
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
rec := httptest.NewRecorder() // captures status, headers, and body
GetUserHandler(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
// Decode and assert the response body
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
t.Fatalf("decoding response: %v", err)
}
if user.ID != 1 {
t.Errorf("user.ID = %d; want 1", user.ID)
}
}
TestMain — Setup and Teardown
Sometimes a test package needs shared, expensive resources — a database connection, a temporary directory, a running server. TestMain lets you set these up once before any tests run and tear them down after all tests complete. Without TestMain, each test would have to manage its own setup, leading to slow, repetitive tests or resource leaks.
var testDB *sql.DB
func TestMain(m *testing.M) {
// Setup — runs once before any test in this package
var err error
testDB, err = sql.Open("postgres", "postgres://localhost/testdb")
if err != nil {
log.Fatalf("opening test db: %v", err)
}
runMigrations(testDB)
// m.Run() executes all the Test* functions in this package
code := m.Run()
// Teardown — runs after all tests, regardless of pass/fail
testDB.Close()
os.Exit(code) // must call os.Exit with m.Run()'s return code
}
Benchmarks
Benchmark functions measure the performance of a piece of code and report time per operation and allocations per operation. They are the right tool when you want to compare two implementations, verify a performance regression hasn’t snuck in, or understand the cost of an operation. The testing framework automatically adjusts b.N (the loop count) until the benchmark runs long enough to produce a stable measurement.
func BenchmarkAdd(b *testing.B) {
// b.N is set by the framework — run the operation exactly b.N times
for i := 0; i < b.N; i++ {
Add(3, 4)
}
}
func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteString("hello")
}
_ = sb.String()
}
}
go test -bench=. ./... # run all benchmarks
go test -bench=BenchmarkAdd -benchmem ./... # include allocation stats
# BenchmarkAdd-8 1000000000 0.23 ns/op 0 B/op 0 allocs/op
-benchmem shows memory allocations per operation. High allocs/op is often the first thing to optimise.
Using testify
Go’s standard testing package is minimal by design — it doesn’t include assertion helpers. Writing if got != want { t.Errorf(...) } for every check is verbose. testify provides a concise assertion API that produces clear failure messages automatically. require stops the test on failure (like t.Fatal); assert records the failure and continues (like t.Error). Use require for preconditions, assert for independent checks.
go get github.com/stretchr/testify
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserService(t *testing.T) {
svc := NewUserService()
user, err := svc.Create("Alice", "[email protected]")
// require stops the test immediately if err != nil — no point asserting user fields on a nil user
require.NoError(t, err)
require.NotNil(t, user)
// assert records failures but continues — all assertions run even if one fails
assert.Equal(t, "Alice", user.Name)
assert.Equal(t, "[email protected]", user.Email)
assert.NotZero(t, user.ID)
assert.True(t, user.CreatedAt.Before(time.Now()))
}
func TestUserNotFound(t *testing.T) {
svc := NewUserService()
_, err := svc.Find(99999)
// assert.ErrorIs wraps errors.Is — works with wrapped errors
assert.ErrorIs(t, err, ErrNotFound)
}
testify/mock for mocking interfaces
Mocking lets you replace a real dependency (a database, an email sender, an HTTP client) with a controlled fake that records calls and returns predetermined values. This makes tests fast and deterministic — no real network or disk I/O. testify/mock generates the boilerplate; you set up expectations with On and verify them with AssertExpectations.
import "github.com/stretchr/testify/mock"
// MockEmailer implements the Emailer interface using testify/mock
type MockEmailer struct {
mock.Mock
}
func (m *MockEmailer) Send(to, subject, body string) error {
args := m.Called(to, subject, body) // records the call and returns preset values
return args.Error(0)
}
func TestWelcomeEmail(t *testing.T) {
mailer := new(MockEmailer)
// Expect Send to be called with these exact arguments — return nil (no error)
mailer.On("Send", "[email protected]", "Welcome!", mock.AnythingOfType("string")).
Return(nil)
svc := NewUserService(mailer)
svc.RegisterUser("Alice", "[email protected]")
// Verify that Send was called exactly as expected
mailer.AssertExpectations(t)
}
Test Coverage
Coverage reports show which lines of code are exercised by your tests and which are not. Use them to find untested error paths, edge cases, and branches — not to chase a 100% number. A test that exercises every line but asserts nothing is worse than no test at all.
go test -cover ./...
# ok myapp/internal/math coverage: 87.5% of statements
# Generate an HTML coverage report to see exactly which lines are missed
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
Parallel Tests
By default, Go runs test functions sequentially within a package. Calling t.Parallel() at the start of a test allows it to run concurrently with other parallel tests, significantly reducing the total wall-clock time of a large test suite. Table-driven parallel tests require capturing the loop variable before the subtest goroutine starts — a common gotcha that causes all subtests to use the last case’s values.
func TestParallelSafe(t *testing.T) {
t.Parallel() // this test can run at the same time as other parallel tests
// test body...
}
// Table-driven + parallel
func TestProcess(t *testing.T) {
cases := []struct{ input, want string }{
{"a", "A"},
{"b", "B"},
}
for _, tt := range cases {
tt := tt // capture the loop variable — each subtest closure gets its own copy
t.Run(tt.input, func(t *testing.T) {
t.Parallel() // subtest runs concurrently with other subtests in this function
got := strings.ToUpper(tt.input)
assert.Equal(t, tt.want, got)
})
}
}