Production error handling should add useful context. Test setup is different.

If a setup operation fails and the test can do nothing meaningful except stop, repeatedly writing this adds noise:

1n, err := f.Write(data)
2if err != nil {
3    t.Fatal(err)
4}
5_ = n

The error is not being classified, wrapped, or asserted. The test is simply saying: this setup operation must succeed.

For that case, I prefer a small must helper.

A minimal generic helper

 1func must[T any](t testing.TB, value T, err error) T {
 2    t.Helper()
 3    if err != nil {
 4        t.Fatal(err)
 5    }
 6    return value
 7}
 8
 9func mustOK(t testing.TB, err error) {
10    t.Helper()
11    if err != nil {
12        t.Fatal(err)
13    }
14}

Go’s multiple-return argument expansion makes this convenient:

1f := must(t, os.CreateTemp(t.TempDir(), "fixture-*"))
2_ = must(t, f.Write(data))
3mustOK(t, f.Close())

The important part is not saving lines. It is moving mechanical failure handling out of the scenario so the test body exposes intent.

Use t.Helper() every time

A helper that fails a test should call t.Helper() so the reported line points to the caller rather than the helper implementation.

That applies to must, fixture builders, comparison helpers, and setup functions:

1func mustZip(t testing.TB, files map[string][]byte) []byte {
2    t.Helper()
3    // build archive or fail the test
4}

Without t.Helper(), abstraction makes failures harder to trace. With it, the helper removes noise without hiding location.

Do not use must when the error is part of the behaviour

This is still useful:

1err := service.Save(input)
2if !errors.Is(err, fs.ErrPermission) {
3    t.Fatalf("Save() error = %v, want permission error", err)
4}

The error is the assertion. Hiding it behind must would destroy the test.

Likewise, keep explicit setup handling when the extra message adds real diagnostic value:

1manifest, err := parseManifest(data)
2if err != nil {
3    t.Fatalf("parse embedded baseline manifest: %v", err)
4}

The rule is narrower: use must when the only information being added is an if err != nil branch that immediately calls t.Fatal(err).

Prefer domain-shaped setup helpers over repeated mechanics

Once the same setup sequence appears several times, do not merely wrap each syscall independently. Extract the meaningful operation.

Instead of:

1buf := new(bytes.Buffer)
2zw := zip.NewWriter(buf)
3f := must(t, zw.Create("example/module@v1.0.0/go.mod"))
4_ = must(t, f.Write(modData))
5mustOK(t, zw.Close())

prefer:

1archive := mustModuleZip(t, "example/module", "v1.0.0", map[string][]byte{
2    "go.mod": modData,
3})

The helper name explains why the mechanics exist.

Helpers should remove plumbing, not hide the test

Good test helpers usually fall into a few categories:

  • construct a fixture;
  • configure an injected dependency;
  • start a deterministic fake service;
  • read or compare a result;
  • assert one reusable invariant.

A warning sign is a helper named setupTest, doEverything, or runCase that hides most of the scenario and requires reading its implementation to understand the test.

Prefer helpers that expose useful nouns and verbs:

1proxy := newModuleProxy(t, modules)
2config := configFS(t, "one-project")
3got := runGeneration(t, config, proxy)
4assertPackage(t, got, "example/module", "v1.0.0")

A reviewer can understand that sequence without following every helper.

Reuse should be low-risk

Test helper reuse is valuable when the helper has a narrow, stable contract.

For example, an archive builder can be safely reused across checksum, parser, HTTP, and extraction tests because its job is only to produce an archive. Changing the product behaviour does not require changing the helper.

By contrast, a helper that constructs a product object, executes it, rewrites globals, reads files, and verifies output is tightly coupled to a specific test path. Reusing it tends to spread assumptions.

The test suite should become drier where repetition is mechanical, not where duplication is carrying meaningful scenario differences.

Standard practice

For test setup:

  • use a small generic must/mustOK helper for uninteresting setup failures;
  • call t.Helper() in every helper that can fail the test;
  • keep product errors explicit when they are part of the assertion;
  • extract repeated mechanical construction into domain-shaped fixture helpers;
  • avoid helpers that perform the entire test invisibly;
  • prefer reusable helpers whose contract is independent of the behaviour currently being tested.

The result should be fewer error-checking branches and more visible test intent.