rgoussu@goussu: ~/library/go/testing
~/library/go/testing cat unit-testing.md

Unit testing — the testing package, table tests, doubles

# Go unit testing with the stdlib testing package — table-driven tests, subtests, golden files, the assertion-library debate, and test doubles.

Conceptsaved 2026-08-09 #go#testing#testify#mocking#tooling

Overview

Go's unit-testing story is deliberately minimal: the stdlib testing package provides *testing.T, subtests, parallelism, cleanup and helpers, and go test compiles and runs any _test.go file — no framework, no runner to configure. The community's contribution is idiom rather than machinery: table-driven tests as the universal pattern, hand-rolled fakes as the default double, and a long-running debate between testify's assertions and the purist if got != want + go-cmp style.

Key points

  • *testing.T is the whole API surface: t.Errorf (fail and continue), t.Fatalf (fail and stop), t.Skip, t.Log — failure is reporting, not exceptions.
  • Subtests via t.Run: named, individually addressable (go test -run TestFoo/case), independently reported; the enabling mechanism for table-driven tests.
  • t.Parallel() marks a test (or subtest) safe to run alongside its siblings — cheap wall-clock wins, but shared state and captured loop variables become hazards.
  • t.Cleanup(f) replaces defer-based teardown and composes across helpers; t.Helper() makes a helper's failures report the caller's line, essential for any shared assertion function.
  • t.TempDir() hands out an auto-removed per-test directory; t.Setenv scopes an environment variable to the test (and forbids t.Parallel — env is process-global).
  • Table-driven tests are THE idiom: cases as data, one loop, one t.Run per case. Adding a case is adding a struct literal, not a function.
  • Golden files (testdata/*.golden) for large or structured expected output, with an -update flag to regenerate — review the diff in Git, not in your head.
  • Hand-rolled fakes are the Go default: small consumer-side interfaces make a ten-line in-memory fake cheaper and clearer than a mocking framework.
  • Race detector as a habit: go test -race in CI always; unit tests are where data races are cheapest to find.

Details

Assertions: testify vs go-cmp + plain if

Style Stack Trade-off
Fluent assertions stretchr/testify (assert continues, require stops) Ubiquitous, readable, rich failure messages; a dependency and a mini-DSL
Stdlib purist if got != want { t.Errorf(...) } + google/go-cmp for deep diffs Zero magic, cmp.Diff output is excellent for structs; more boilerplate

go-cmp earns its place either way: cmp.Diff(want, got) with cmpopts (ignore fields, tolerate float error, sort slices) beats reflect.DeepEqual's silent false negatives. Pick one style per repository; mixed suites read worse than either extreme.

Test doubles

  • Fake first: define the interface where it is consumed (Go interfaces are satisfied implicitly), then write an in-memory implementation. It serves the whole suite and keeps tests black-box.
  • Generated mocks when interactions matter: uber-go/mock (the maintained gomock fork) generates strict, expectation-based mocks; vektra/mockery generates testify-flavoured ones. Reserve them for boundaries where the outgoing call is the behaviour — an interaction-verifying suite couples to call sequence and shatters on refactor.
  • Time is a dependency: inject a clock (func() time.Time field, or a small clock interface) instead of calling time.Now in domain logic; sleeping in tests is a flake factory.

Golden files

Store expected output under testdata/ (ignored by the build), compare with cmp.Diff, and regenerate behind a flag: var update = flag.Bool("update", false, "rewrite golden files"). Works for rendered templates, JSON payloads, CLI output — anything too big to inline.

Examples

func TestParseDuration(t *testing.T) {
    cases := []struct {
        name    string
        in      string
        want    time.Duration
        wantErr bool
    }{
        {name: "seconds", in: "15s", want: 15 * time.Second},
        {name: "composite", in: "1h30m", want: 90 * time.Minute},
        {name: "garbage", in: "soon", wantErr: true},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            got, err := ParseDuration(tc.in)
            if tc.wantErr {
                if err == nil {
                    t.Fatalf("ParseDuration(%q): expected error, got %v", tc.in, got)
                }
                return
            }
            if err != nil {
                t.Fatalf("ParseDuration(%q): %v", tc.in, err)
            }
            if diff := cmp.Diff(tc.want, got); diff != "" {
                t.Errorf("ParseDuration(%q) mismatch (-want +got):\n%s", tc.in, diff)
            }
        })
    }
}

Related