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:#f3e5f5

Basic 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 --> Result
func 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 with t.Run for 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.Fatal for failures that stop the test, t.Error for ones that can continue — use Fatal when the next step doesn’t make sense if this step fails.
  • t.Cleanup is better than defer for test cleanup because it always runs even if t.Fatal is 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 manual os.TempDir() usage.
  • Interfaces for testability — dependencies defined as interfaces allow replacement with mocks in tests without changing production code.
  • httptest.NewRecorder and httptest.NewRequest for testing HTTP handlers — no real server needed, tests are faster and isolated.
  • go test -race to 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.

← Previous: Crypto   Next: Flag →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact