Testing #
Testing is a first-class citizen in Go — not something added later, but a core part of the toolchain from the start. The testing package provides a clean, minimal framework: just create a _test.go file, write TestXxx functions, and run go test. No mandatory assertion library, no external test runner, no complicated configuration. This philosophy encourages simple, explicit tests. Beyond ordinary unit tests, Go also supports benchmarks with BenchmarkXxx for measuring performance, subtests with t.Run for better organization, and fuzzing with FuzzXxx for automatically finding edge cases. Understanding testing in Go well is an investment whose benefits are immediately felt — code that’s easy to test is usually also well-designed code.
An Overview of the testing Package #
flowchart TD
T["package testing"] --> Unit["Unit Tests\nTestXxx(t *testing.T)"]
T --> Bench["Benchmarks\nBenchmarkXxx(b *testing.B)"]
T --> Fuzz["Fuzzing\nFuzzXxx(f *testing.F)"]
T --> Example["Examples\nExampleXxx()"]
Unit --> TA["t.Error / t.Errorf\ncontinue the test even on failure"]
Unit --> TB["t.Fatal / t.Fatalf\nstop the test immediately"]
Unit --> TC["t.Run\nsubtests"]
Unit --> TD["t.Helper\nmark as a helper"]
Unit --> TE["t.Cleanup\nrun after the test finishes"]
Unit --> TF["t.Parallel\nrun in parallel"]
Unit --> TG["t.Skip / t.Skipf\nskip a test"]
Unit --> TH["t.TempDir\ntemporary directory"]
Bench --> BA["b.N — the iteration count"]
Bench --> BB["b.ResetTimer\nreset the timer after setup"]
Bench --> BC["b.ReportAllocs\nreport allocations"]
Bench --> BD["b.RunParallel\nparallel benchmarks"]
style T fill:#4f86c6,color:#fff
style Unit fill:#e8f5e9
style Bench fill:#e3f2fd
style Fuzz fill:#fff3e0
style Example fill:#f3e5f5Basic Unit Tests #
Test files in Go must end in _test.go and be in the same package (or the _test package for black-box testing). Test function names must start with Test followed by a capital letter:
// Directory structure
// myapp/
// ├── calc.go
// └── calc_test.go
// calc.go
package calc
func Add(a, b int) int {
return a + b
}
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("can't divide by zero")
}
return a / b, nil
}
func IsPalindrome(s string) bool {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
if runes[i] != runes[j] {
return false
}
}
return true
}
// calc_test.go
package calc
import (
"testing"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d, want 5", result)
}
}
func TestDivide(t *testing.T) {
// Test the normal case
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("Divide(10, 2) returned an unexpected error: %v", err)
}
if result != 5.0 {
t.Errorf("Divide(10, 2) = %f, want 5.0", result)
}
// Test division by zero
_, err = Divide(10, 0)
if err == nil {
t.Error("Divide(10, 0) should return an error")
}
}
t.Error vs t.Fatal #
func TestErrorFatalDifference(t *testing.T) {
// t.Error — record the failure but CONTINUE the test
// Use it when you want to see all failures at once
result := Add(1, 1)
if result != 2 {
t.Errorf("Add(1, 1) = %d, want 2", result)
// The test continues after this
}
// t.Fatal — record the failure and STOP the test immediately
// Use it when the next step doesn't make sense if this step fails
conn, err := openConnection()
if err != nil {
t.Fatalf("failed to open the connection: %v", err)
// The test stops here — code after this isn't executed
}
defer conn.Close()
// This won't be executed if t.Fatal was called
conn.Send("ping")
}
Table-Driven Tests — The Most Idiomatic Pattern #
Table-driven testing is the most recommended pattern in Go — define all test cases in one table, then iterate and run each one:
flowchart LR
subgraph Table["Test Table ([]struct{...})"]
TC1["Case 1\ninput: 2,3\nexpect: 5"]
TC2["Case 2\ninput: -1,1\nexpect: 0"]
TC3["Case 3\ninput: 0,0\nexpect: 0"]
TC4["Case 4\n(edge case)\ninput: MaxInt,1\nexpect: error"]
end
subgraph Loop["for _, tc := range tests"]
Run["t.Run(tc.name, func(t))"]
end
subgraph Result["Results"]
R1["PASS: Case 1"]
R2["PASS: Case 2"]
R3["PASS: Case 3"]
R4["FAIL: Case 4 — error detail"]
end
Table --> Loop --> Resultfunc TestAddTableDriven(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"positive + positive", 2, 3, 5},
{"negative + positive", -1, 1, 0},
{"zero + zero", 0, 0, 0},
{"large + large", 1000000, 2000000, 3000000},
{"negative + negative", -5, -3, -8},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := Add(tc.a, tc.b)
if result != tc.want {
t.Errorf("Add(%d, %d) = %d, want %d",
tc.a, tc.b, result, tc.want)
}
})
}
}
// A table-driven test for a function returning an error
func TestDivideTableDriven(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normal division", 10, 2, 5.0, false},
{"division by zero", 10, 0, 0, true},
{"negative", -6, 2, -3.0, false},
{"fraction", 1, 3, 0.3333333333333333, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := Divide(tc.a, tc.b)
if tc.wantErr {
if err == nil {
t.Errorf("Divide(%g, %g) wanted an error, but there was none", tc.a, tc.b)
}
return
}
if err != nil {
t.Fatalf("Divide(%g, %g) unexpected error: %v", tc.a, tc.b, err)
}
if result != tc.want {
t.Errorf("Divide(%g, %g) = %g, want %g", tc.a, tc.b, result, tc.want)
}
})
}
}
Subtests with t.Run #
t.Run creates subtests that can be run individually, giving more organized output:
// Run only a specific subtest:
// go test -run TestIsPalindrome/single_word
func TestIsPalindrome(t *testing.T) {
t.Run("single word", func(t *testing.T) {
if !IsPalindrome("a") {
t.Error("'a' should be a palindrome")
}
})
t.Run("palindromic sentence", func(t *testing.T) {
if !IsPalindrome("kayak") {
t.Error("'kayak' should be a palindrome")
}
})
t.Run("not a palindrome", func(t *testing.T) {
if IsPalindrome("hello") {
t.Error("'hello' isn't a palindrome")
}
})
t.Run("empty string", func(t *testing.T) {
if !IsPalindrome("") {
t.Error("an empty string should be a palindrome")
}
})
}
Test Helpers — t.Helper #
t.Helper() marks a function as a helper — so when a test fails, Go reports the line in the helper’s caller, not inside the helper:
// Without t.Helper — the output points to assertEqual, not the caller
func assertEqual(t *testing.T, got, want interface{}) {
if got != want {
t.Errorf("got %v, want %v", got, want)
// Output: calc_test.go:15: got 4, want 5
// Line 15 is inside assertEqual, not in TestAdd!
}
}
// With t.Helper — the output points to the caller
func assertEqualGood(t *testing.T, got, want interface{}) {
t.Helper() // mark as a helper
if got != want {
t.Errorf("got %v, want %v", got, want)
// Output: calc_test.go:8: got 4, want 5
// Line 8 is in TestAdd, much more informative!
}
}
// More complete helpers
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func assertError(t *testing.T, err error, contains string) {
t.Helper()
if err == nil {
t.Fatal("wanted an error, but there was none")
}
if contains != "" && !strings.Contains(err.Error(), contains) {
t.Errorf("error %q doesn't contain %q", err.Error(), contains)
}
}
func assertEqual2(t *testing.T, got, want interface{}, format string, args ...interface{}) {
t.Helper()
if got != want {
msg := fmt.Sprintf(format, args...)
t.Errorf("%s: got %v, want %v", msg, got, want)
}
}
// Usage
func TestWithHelpers(t *testing.T) {
result := Add(2, 3)
assertEqualGood(t, result, 5) // this line is reported if it fails
_, err := Divide(10, 0)
assertError(t, err, "zero")
}
t.Cleanup and t.TempDir #
// t.Cleanup — run a function when the test finishes (success or failure)
// Better than defer in many cases because it's registered with the test runner
func TestWithCleanup(t *testing.T) {
// Setup
server := startTestServer()
t.Cleanup(func() {
server.Close() // automatically called when the test finishes
})
db := createTestDB()
t.Cleanup(func() {
db.DropTestTable()
db.Close()
})
// Test using server and db
// Cleanups are called in LIFO order (last registered, first called)
}
// t.TempDir — create a temporary directory that's automatically cleaned up
func TestFileOperations(t *testing.T) {
tmpDir := t.TempDir()
// The directory is automatically deleted when the test finishes — no manual cleanup needed
filePath := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(filePath, []byte("test content"), 0644)
if err != nil {
t.Fatalf("failed to write the file: %v", err)
}
// Read it back and verify
data, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read the file: %v", err)
}
if string(data) != "test content" {
t.Errorf("file content doesn't match: %q", data)
}
}
t.Parallel — Parallel Tests #
t.Parallel() allows a test to run in parallel with other tests that also call Parallel():
func TestA(t *testing.T) {
t.Parallel() // this test can run in parallel with TestB and TestC
time.Sleep(100 * time.Millisecond)
// ...
}
func TestB(t *testing.T) {
t.Parallel()
time.Sleep(100 * time.Millisecond)
// ...
}
// Without t.Parallel: A → B → C = 300ms
// With t.Parallel: A, B, C simultaneously = ~100ms
// IMPORTANT: don't capture the loop variable in parallel subtests!
func TestParallelTableDriven(t *testing.T) {
tests := []struct {
name string
input int
}{
{"case 1", 1},
{"case 2", 2},
{"case 3", 3},
}
for _, tc := range tests {
tc := tc // REQUIRED: shadow the variable for parallel subtests (Go < 1.22)
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Use tc.input here — safe because it's been shadowed
_ = tc.input
})
}
}
t.Skip — Skipping Tests #
func TestNeedsDatabase(t *testing.T) {
// Skip if the environment variable isn't set
if os.Getenv("DATABASE_URL") == "" {
t.Skip("skip: DATABASE_URL not set")
}
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
// ...
}
func TestNeedsInternet(t *testing.T) {
if testing.Short() {
t.Skip("skip: -short mode")
}
// A test needing an internet connection
}
// Run with: go test -short ./...
// to skip slow tests
Benchmarks #
Benchmarks measure code performance — how long per operation, how much memory is allocated:
// BenchmarkXxx — names must start with Benchmark
func BenchmarkAdd(b *testing.B) {
// b.N is set automatically by the test runner
// starting small and increased until the results are stable
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// A benchmark with setup
func BenchmarkIsPalindrome(b *testing.B) {
input := "kayak"
b.ResetTimer() // reset the timer after setup (if any)
for i := 0; i < b.N; i++ {
IsPalindrome(input)
}
}
// A benchmark reporting memory allocations
func BenchmarkMakeSlice(b *testing.B) {
b.ReportAllocs() // report allocations per operation
for i := 0; i < b.N; i++ {
s := make([]int, 100)
_ = s
}
}
// Run the benchmark:
// go test -bench=. -benchmem ./...
//
// Output:
// BenchmarkAdd-8 1000000000 0.3 ns/op
// BenchmarkIsPalindrome-8 50000000 25.0 ns/op 0 allocs/op
// BenchmarkMakeSlice-8 10000000 120.0 ns/op 1 allocs/op 808 B/op
Benchmark Subtests #
func BenchmarkStringsVsBytes(b *testing.B) {
data := strings.Repeat("a", 1000)
b.Run("strings.Contains", func(b *testing.B) {
for i := 0; i < b.N; i++ {
strings.Contains(data, "zzz")
}
})
b.Run("strings.Index", func(b *testing.B) {
for i := 0; i < b.N; i++ {
strings.Index(data, "zzz")
}
})
b.Run("regexp", func(b *testing.B) {
re := regexp.MustCompile("zzz")
b.ResetTimer()
for i := 0; i < b.N; i++ {
re.MatchString(data)
}
})
}
Parallel Benchmarks #
func BenchmarkParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// The code being tested in parallel
Add(1, 2)
}
})
}
Fuzzing #
Fuzzing automatically generates unexpected inputs to find bugs and edge cases. Go has built-in fuzzing support since Go 1.18:
// FuzzXxx — names must start with Fuzz
func FuzzIsPalindrome(f *testing.F) {
// Seed corpus — initial example inputs
f.Add("kayak")
f.Add("hello")
f.Add("")
f.Add("a")
f.Fuzz(func(t *testing.T, input string) {
// The fuzzer will call this with various input variations
result := IsPalindrome(input)
// Properties that must always hold (invariants)
// The reverse of a palindrome must still be a palindrome
if result {
reversed := reverseString(input)
if !IsPalindrome(reversed) {
t.Errorf("palindrome %q reversed %q is not a palindrome", input, reversed)
}
}
})
}
func reverseString(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// Run fuzzing:
// go test -fuzz=FuzzIsPalindrome -fuzztime=30s
Testing with Interfaces and Mocks #
One of Go’s testing strengths is interfaces — by defining dependencies as interfaces, you can replace real implementations with mocks in tests:
flowchart LR
subgraph Production["Production"]
Service["UserService"] --> RealDB["PostgresUserRepo\nreal implementation"]
end
subgraph Test["Test"]
ServiceT["UserService"] --> MockDB["MockUserRepo\ntest implementation"]
end
subgraph Interface["Interface"]
I["UserRepository\n+ FindByID(id) (*User, error)\n+ Save(user) error\n+ Delete(id) error"]
end
RealDB --> Interface
MockDB --> Interface
Service --> Interface
ServiceT --> Interface
style Interface fill:#4f86c6,color:#fff
style MockDB fill:#e8f5e9
style RealDB fill:#e3f2fd// Interface definition
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id int) error
}
// Production implementation
type PostgresUserRepo struct {
db *sql.DB
}
// Mock implementation for tests
type MockUserRepo struct {
users map[int]*User
Errors map[string]error // error injection for failing test cases
}
func NewMockUserRepo() *MockUserRepo {
return &MockUserRepo{
users: make(map[int]*User),
Errors: make(map[string]error),
}
}
func (m *MockUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
if err := m.Errors["FindByID"]; err != nil {
return nil, err
}
user, exists := m.users[id]
if !exists {
return nil, ErrNotFound
}
return user, nil
}
func (m *MockUserRepo) Save(ctx context.Context, user *User) error {
if err := m.Errors["Save"]; err != nil {
return err
}
m.users[user.ID] = user
return nil
}
func (m *MockUserRepo) Delete(ctx context.Context, id int) error {
if err := m.Errors["Delete"]; err != nil {
return err
}
delete(m.users, id)
return nil
}
// A service using the interface
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("ID must be positive")
}
return s.repo.FindByID(ctx, id)
}
// Test using the mock
func TestUserServiceGetUser(t *testing.T) {
ctx := context.Background()
t.Run("user found", func(t *testing.T) {
mock := NewMockUserRepo()
mock.Save(ctx, &User{ID: 1, Name: "Budi"})
svc := NewUserService(mock)
user, err := svc.GetUser(ctx, 1)
assertNoError(t, err)
if user.Name != "Budi" {
t.Errorf("user name = %q, want 'Budi'", user.Name)
}
})
t.Run("user not found", func(t *testing.T) {
mock := NewMockUserRepo()
svc := NewUserService(mock)
_, err := svc.GetUser(ctx, 999)
assertError(t, err, "")
})
t.Run("invalid ID", func(t *testing.T) {
mock := NewMockUserRepo()
svc := NewUserService(mock)
_, err := svc.GetUser(ctx, -1)
assertError(t, err, "positive")
})
t.Run("error from repository", func(t *testing.T) {
mock := NewMockUserRepo()
mock.Errors["FindByID"] = fmt.Errorf("database connection lost")
svc := NewUserService(mock)
_, err := svc.GetUser(ctx, 1)
assertError(t, err, "")
})
}
Test Coverage #
# Run tests with coverage
go test -cover ./...
# Output:
# ok github.com/user/myapp/calc 0.003s coverage: 85.7% of statements
# Create an HTML coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Per-function coverage
go tool cover -func=coverage.out
# Run tests for a specific package only
go test ./internal/service/...
# Run tests with a specific name
go test -run TestAdd ./...
# Run tests with the race detector
go test -race ./...
# Run tests with a timeout
go test -timeout 30s ./...
# A useful flag combination
go test -v -race -cover -timeout 60s ./...
Production Usage Patterns #
Testing HTTP Handlers #
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestListProductsHandler(t *testing.T) {
// httptest.NewRecorder — a fake ResponseWriter that records the response
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/products?limit=10", nil)
r.Header.Set("Authorization", "Bearer test-token")
// Run the handler
listProductsHandler(w, r)
// Check the status code
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
// Check the Content-Type
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Content-Type = %q, want JSON", contentType)
}
// Decode and check the response
var response []Product
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode the response: %v", err)
}
if len(response) == 0 {
t.Error("empty response")
}
}
// Test with a full router/mux
func TestServerIntegration(t *testing.T) {
mux := http.NewServeMux()
registerRoutes(mux)
// httptest.NewServer — a real HTTP server on a random port
server := httptest.NewServer(mux)
defer server.Close()
// Send a real request to the test server
resp, err := http.Get(server.URL + "/api/products")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
Test with TestMain — Global Setup and Teardown #
// TestMain runs before all tests in the package
func TestMain(m *testing.M) {
// Global setup before all tests
db = setupTestDatabase()
populateTestData()
// m.Run() runs all the tests
exitCode := m.Run()
// Cleanup after all tests finish
cleanDatabase()
db.Close()
os.Exit(exitCode)
}
var db *sql.DB
func setupTestDatabase() *sql.DB {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatalf("failed to open the test DB: %v", err)
}
// Run migrations
if err := runMigrations(db); err != nil {
log.Fatalf("failed to migrate: %v", err)
}
return db
}
Golden File Tests — Snapshot Testing #
// Golden file tests: compare output with a reference file
func TestFormatReport(t *testing.T) {
input := ReportData{
Title: "Monthly Report",
Period: "March 2024",
Total: 15000000,
ItemCount: 42,
}
result := FormatReport(input)
goldenPath := filepath.Join("testdata", "monthly_report.golden")
// Update the golden file with: go test -update
if *flagUpdate {
os.MkdirAll("testdata", 0755)
os.WriteFile(goldenPath, []byte(result), 0644)
return
}
// Read and compare with the golden file
expected, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("failed to read the golden file: %v\n"+
"Run 'go test -update' to create the golden file", err)
}
if string(expected) != result {
t.Errorf("output doesn't match the golden file\n\nGot:\n%s\n\nWant:\n%s",
result, expected)
}
}
var flagUpdate = flag.Bool("update", false, "update golden files")
When to Switch to Alternatives #
Keep using the testing package if:
✓ Unit tests, benchmarks, fuzzing — all common cases
✓ Tests wanting zero external dependencies
✓ Idiomatic Go: table-driven tests, t.Helper, t.Run
Consider an assertion library if:
✗ The team is more productive with expressive assertions
→ testify/assert — assert.Equal, assert.NoError, etc.
→ testify/require — like assert but calls t.FailNow()
→ gomock — a mock generator from interfaces
Consider other testing frameworks if:
✗ BDD-style tests (Given/When/Then)
→ goconvey, ginkgo/gomega
✗ Property-based testing (like QuickCheck)
→ gopter, rapid
Consider integration test runners if:
✗ Tests needing Docker containers (databases, Redis, etc.)
→ testcontainers-go
✗ Browser end-to-end tests
→ playwright-go, chromedp
Summary #
- Table-driven tests are the most idiomatic pattern in Go — define all cases in a
[]struct{...}and iterate witht.Runfor organized, easily identifiable output.t.Helper()in every helper function so error messages point to the helper’s caller, not inside the helper — this makes debugging much easier.t.Fatalfor failures that stop the test,t.Errorfor ones that can continue — useFatalwhen the next step doesn’t make sense if this step fails.t.Cleanupis better thandeferfor test cleanup because it always runs even ift.Fatalis called, and it’s registered with the test runner.t.TempDir()for temporary directories that are automatically cleaned up — no manual cleanup needed, cleaner than manualos.TempDir()usage.- Interfaces for testability — dependencies defined as interfaces allow replacement with mocks in tests without changing production code.
httptest.NewRecorderandhttptest.NewRequestfor testing HTTP handlers — no real server needed, tests are faster and isolated.go test -raceto detect race conditions — run this in CI/CD, especially for concurrent code.- Benchmarks with
b.ReportAllocs()to monitor memory allocations — important for code called thousands of times per second.- Fuzzing to find edge cases you haven’t thought of — very effective for parsing, encoding/decoding, and data manipulation functions.