Skip to main content
Go intermediate Lesson 19 of 25

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", "alice@example.com")

    // 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, "alice@example.com", 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", "alice@example.com", "Welcome!", mock.AnythingOfType("string")).
        Return(nil)

    svc := NewUserService(mailer)
    svc.RegisterUser("Alice", "alice@example.com")

    // 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)
        })
    }
}

Frequently Asked Questions

How do I run only a specific test?
Use go test -run TestName ./... where TestName is a regular expression matched against test function names. For example, go test -run TestAdd will run TestAdd and TestAddNegative but not TestMultiply.
What are table-driven tests?
Table-driven tests define a slice of test cases (inputs and expected outputs) and loop over them. This is the idiomatic Go approach — it eliminates duplicated test code and makes adding new cases trivial.
What is the difference between t.Error and t.Fatal?
t.Error logs a failure and continues the test. t.Fatal logs a failure and stops the test immediately (calls runtime.Goexit). Use t.Fatal when subsequent steps would panic or produce misleading output if the current step failed.