Go’s testing package is small, fast, and durable. The patterns that emerge in production codebases are uniform. This post is the working set.

Table-driven tests

The Go canonical pattern:

func TestParseDuration(t *testing.T) {
    tests := []struct {
        name string
        in   string
        want time.Duration
        err  bool
    }{
        {"seconds", "5s", 5 * time.Second, false},
        {"minutes", "3m", 3 * time.Minute, false},
        {"invalid", "5z", 0, true},
        {"empty", "", 0, true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseDuration(tt.in)
            if (err != nil) != tt.err {
                t.Fatalf("err = %v, want err? %v", err, tt.err)
            }
            if got != tt.want {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

Each entry runs as a subtest. Failure messages include the name. Rerun a single case with go test -run TestParseDuration/seconds.

Subtests for isolation

func TestUserService(t *testing.T) {
    db := setupDB(t)
    svc := NewService(db)
    
    t.Run("create", func(t *testing.T) {
        u, err := svc.Create(ctx, "[email protected]")
        require.NoError(t, err)
        require.NotZero(t, u.ID)
    })
    
    t.Run("update", func(t *testing.T) {
        // ...
    })
}

Each subtest is isolated; failure in one doesn’t skip others. The setup is shared.

testcontainers for real DBs

import "github.com/testcontainers/testcontainers-go/modules/postgres"

func setupDB(t *testing.T) *sql.DB {
    t.Helper()
    ctx := context.Background()
    
    container, err := postgres.Run(ctx, "postgres:17",
        postgres.WithDatabase("test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategyAndDeadline(
            wait.ForLog("ready to accept").WithStartupTimeout(60*time.Second),
        ),
    )
    if err != nil { t.Fatal(err) }
    t.Cleanup(func() { container.Terminate(ctx) })
    
    dsn, _ := container.ConnectionString(ctx)
    db, _ := sql.Open("pgx", dsn)
    runMigrations(t, db)
    return db
}

Real Postgres in 5 seconds; tests against it. Mocks go bye-bye for repository tests.

httptest for HTTP handlers

func TestHandler_Hello(t *testing.T) {
    req := httptest.NewRequest("GET", "/hello?name=world", nil)
    rec := httptest.NewRecorder()
    
    Handler(rec, req)
    
    res := rec.Result()
    if res.StatusCode != 200 {
        t.Fatalf("status = %d", res.StatusCode)
    }
    body, _ := io.ReadAll(res.Body)
    if string(body) != "hello, world" {
        t.Errorf("body = %q", body)
    }
}

In-process; fast; no network. For end-to-end with an actual server: httptest.NewServer.

Golden files

For tests with rich output (HTML rendering, JSON snapshots):

func TestRenderPost(t *testing.T) {
    out := Render(post)
    
    goldenPath := "testdata/post.golden.html"
    if *update {
        os.WriteFile(goldenPath, out, 0644)
    }
    
    want, _ := os.ReadFile(goldenPath)
    if !bytes.Equal(out, want) {
        t.Errorf("output mismatch")
    }
}

var update = flag.Bool("update", false, "update golden files")

Run go test -update to regenerate. Diff in PR review confirms intentionality.

Fuzzing

Built-in since Go 1.18. Free coverage.

func FuzzParseDuration(f *testing.F) {
    seeds := []string{"5s", "3m", "1h", "0", "5z"}
    for _, s := range seeds { f.Add(s) }
    
    f.Fuzz(func(t *testing.T, s string) {
        d, err := ParseDuration(s)
        if err != nil { return }
        if d < 0 {
            t.Errorf("got negative duration %v from %q", d, s)
        }
    })
}
go test -fuzz=FuzzParseDuration -fuzztime=30s

Found bugs in the standard library and major libraries. Run for any parser / decoder.

Parallel tests

func TestX(t *testing.T) {
    t.Parallel()
    // ...
}

For CPU-bound or IO-bound tests that don’t share state. Massive speed-up on multi-core CI runners.

For table-driven:

for _, tt := range tests {
    tt := tt  // (no longer needed in Go 1.22+, but harmless)
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

Mocks via interfaces

type Mailer interface {
    Send(ctx context.Context, to, subject, body string) error
}

type fakeMailer struct {
    sent []sent
}
func (m *fakeMailer) Send(ctx context.Context, to, subject, body string) error {
    m.sent = append(m.sent, sent{to, subject, body})
    return nil
}

func TestSignup(t *testing.T) {
    mail := &fakeMailer{}
    svc := NewSvc(mail)
    
    svc.Signup(ctx, "[email protected]")
    
    if len(mail.sent) != 1 {
        t.Errorf("expected 1 email, got %d", len(mail.sent))
    }
}

Hand-rolled fakes are ~5 lines. No mock generator needed for most cases. For complex interfaces: mockery or gomock.

Test helpers

func mustCreateUser(t *testing.T, svc *Service, email string) *User {
    t.Helper()
    u, err := svc.Create(ctx, email)
    if err != nil { t.Fatal(err) }
    return u
}

t.Helper() makes failure point in the calling test, not in the helper. Critical for readable failures.

TestMain for global setup

func TestMain(m *testing.M) {
    // global setup (e.g., spin up a shared container)
    code := m.Run()
    // global teardown
    os.Exit(code)
}

Use sparingly — most tests should be self-contained. Useful for expensive shared resources.

Common mistakes

1. Mocks for repository tests

DB code should run against a real DB. Mocks of sql.DB give false confidence — schema bugs slip through.

2. No t.Helper()

Failure shows the helper line, not the caller. Always mark helpers.

3. Sleeps in tests

time.Sleep(100 * time.Millisecond) is flaky. Use channels, atomics, or eventually-style polling helpers.

4. Shared state across tests

Test A leaves state; test B runs and finds it. Reset between subtests, or use fresh containers.

5. Coverage as the only target

100% coverage of trivial code teaches nothing. 60% on critical business logic with great assertions teaches a lot.

Layout

cmd/
  app/
    main.go
internal/
  user/
    user.go
    user_test.go        # unit tests
    integration_test.go # //go:build integration
testdata/
  fixtures.json

Tag integration tests:

//go:build integration

package user

Run separately: go test -tags=integration ./...

Read this next

If you want my Go test harness (testcontainers + fixtures + parallel-safe), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .