V
Vel·ToolKit
Simple · Fast · Ready to use
EN
Chapter 20 of 20

Testing & Engineering

the testing package, table-driven, benchmarks, httptest, Fuzz, toolchain

Unit Tests

Test files end in _test.go and live in the same directory as the code under test; the function signature is func TestXxx(t *testing.T), and the name must start with Test followed by an uppercase letter. The go command finds them automatically.

// add.go
package math

func Add(a, b int) int { return a + b }
// add_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    if got != 5 {
        t.Errorf("Add(2,3) = %d, want 5", got)
    }
}
$ go test ./...
$ go test -run TestAdd -v
$ go test -count=1 ./...   # disable the test result cache

Table-Driven Tests + Subtests

Go's most common test pattern: put cases in a slice and use t.Run to make each case an independent subtest. On failure you see exactly which subcase broke, and you can run a single one with -run.

// add_test.go
package math

import "testing"

func TestAdd_Table(t *testing.T) {
    tests := []struct {
        name       string
        a, b, want int
    }{
        {"both zero", 0, 0, 0},
        {"positive", 1, 2, 3},
        {"negative", -1, -2, -3},
    }
    for _, tc := range tests {
        tc := tc // must rebind for Go < 1.22
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel() // run subtests in parallel
            if got := Add(tc.a, tc.b); got != tc.want {
                t.Errorf("got %d, want %d", got, tc.want)
            }
        })
    }
}

Test Helpers: Helper / Cleanup / TempDir

testing.T provides a set of tools to hide low-level detail, clean up resources automatically, and avoid pollution.

// db_test.go
package storage

import (
    "path/filepath"
    "testing"
)

type DB struct{ path string }

func Open(path string) (*DB, error) { return &DB{path: path}, nil }
func (d *DB) Close() error           { return nil }

func mustOpenDB(t *testing.T) *DB {
    t.Helper()         // on failure, points at the caller rather than this line
    dir := t.TempDir() // auto-deleted when the test ends
    db, err := Open(filepath.Join(dir, "a.db"))
    if err != nil {
        t.Fatalf("open: %v", err)
    }
    t.Cleanup(func() { db.Close() }) // more reliable than defer
    return db
}

func TestDBOpen(t *testing.T) {
    db := mustOpenDB(t)
    _ = db
}

Parallel Tests and -race

t.Parallel() runs cases concurrently — faster with more CPUs; also add -race so the race detector surfaces concurrency bugs early.

$ go test -race -parallel=8 ./...

The testdata Directory

go toolchain convention: a directory named testdata is ignored (not compiled, not vetted). It's the right place for test JSON / golden files.

// render_test.go
package render

import (
    "bytes"
    "flag"
    "os"
    "testing"
)

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

func Render(in []byte) []byte { return bytes.ToUpper(in) }

func TestRender(t *testing.T) {
    in, err := os.ReadFile("testdata/input.md")
    if err != nil {
        t.Fatal(err)
    }
    want, _ := os.ReadFile("testdata/expected.html")

    got := Render(in)
    if !bytes.Equal(got, want) {
        if *update { // rewrite golden files with -update
            os.WriteFile("testdata/expected.html", got, 0o644)
            return
        }
        t.Errorf("got:\n%s\nwant:\n%s", got, want)
    }
}

httptest: Testing HTTP Handlers

net/http/httptest provides a fake Server and a ResponseRecorder: neither server nor client tests need to bind a real port.

// api_test.go
package api

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
)

type User struct{ ID int }

func UserHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "user:", r.PathValue("id"))
}

func Fetch(url string) (User, error) {
    resp, err := http.Get(url)
    if err != nil {
        return User{}, err
    }
    defer resp.Body.Close()
    var u User
    err = json.NewDecoder(resp.Body).Decode(&u)
    return u, err
}

// test the handler
func TestUserHandler(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", UserHandler)

    req := httptest.NewRequest("GET", "/users/42", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req)

    if rec.Code != 200 {
        t.Errorf("status = %d", rec.Code)
    }
}

// test client code (the server it hits is fake)
func TestFetch(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, `{"ID":1}`)
    }))
    defer srv.Close()

    got, err := Fetch(srv.URL)
    if err != nil || got.ID != 1 {
        t.Fatalf("Fetch = %+v, %v", got, err)
    }
}

Example Tests: Dual Purpose

An ExampleXxx function is both documentation (shown automatically on pkg.go.dev) and a test — go test compares the // Output: comment against the actual output.

// example_test.go
package math

import "fmt"

func ExampleAdd() {
    fmt.Println(Add(1, 2))
    // Output: 3
}

Benchmarks

A BenchmarkXxx function takes *testing.B; it loops b.N times and Go adjusts N automatically until the measurement is stable. -benchmem reports allocations and bytes per operation.

// bench_test.go
package math

import (
    "strings"
    "testing"
)

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = Add(1, 2)
    }
}

func BenchmarkJoin(b *testing.B) {
    parts := []string{"a", "b", "c"}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = strings.Join(parts, "-")
    }
}
$ go test -bench=. -benchmem -benchtime=2s
$ go test -bench=Add -cpuprofile=cpu.out
$ go tool pprof cpu.out          # enter interactive analysis

Fuzz Testing (Go 1.18+)

A FuzzXxx function takes *testing.F: register seeds with Add, then Fuzz(func(t, ...)) lets Go mutate inputs automatically to find crashes and unexpected panics. Especially good for parsers and protocol layers.

// fuzz_test.go
package math

import (
    "strconv"
    "testing"
)

func FuzzParseInt(f *testing.F) {
    f.Add("42")
    f.Add("-1")
    f.Add("")
    f.Fuzz(func(t *testing.T, s string) {
        v, err := strconv.Atoi(s)
        if err == nil {
            if got := strconv.Itoa(v); got != s {
                t.Errorf("round trip: %q -> %d -> %q", s, v, got)
            }
        }
    })
}
$ go test -fuzz=FuzzParseInt -fuzztime=30s

Coverage

$ go test -cover ./...
$ go test -coverprofile=cov.out && go tool cover -html=cov.out
$ go test -coverpkg=./... ./...      # include cross-package calls

Mocks and Dependency Injection

Go recommends injecting dependencies via interfaces; in tests, pass a simple stub struct — no mock framework needed. If an interface has many methods, you can generate them with gomock / mockery.

// notify_test.go
package app

import "testing"

type User struct{ Email string }

type Mailer interface {
    Send(to, body string) error
}

type service struct{ mail Mailer }

func (s *service) Notify(u User) error {
    return s.mail.Send(u.Email, "hello")
}

// test stub
type fakeMail struct{ sent []string }

func (f *fakeMail) Send(to, body string) error {
    f.sent = append(f.sent, to)
    return nil
}

func TestNotify(t *testing.T) {
    m := &fakeMail{}
    s := &service{mail: m}
    s.Notify(User{Email: "a@b.c"})
    if len(m.sent) != 1 {
        t.Fatal("not sent")
    }
}

Engineering Toolchain

  • gofmt / goimports: the one formatting standard; run automatically on editor save
  • go vet: built-in static checks
  • staticcheck / golangci-lint: aggregate multiple linters, a CI staple
  • go build -ldflags "-s -w -X main.version=$(git describe)": slim down + inject version
  • go install puts a command into $GOBIN; go run runs a main package directly
  • cross-compilation: GOOS=linux GOARCH=amd64 go build -o app
  • go generate: trigger code generation (stringer / mockgen / sqlc, etc.)
  • go tool pprof / trace: profiling and scheduler visualization