Skip to content
gohacks.dev
Testing

Adopt the Newest Go Testing Features in an Old Codebase

Adopt newer Go testing features in an old codebase: t.Context, t.Chdir, b.Loop, t.Attr, t.Output and testing/synctest, with the go.mod rules that gate each.

7 min read
Summarize in AI
Flat illustration of a test report with one failing test.

Go's testing package has gained useful pieces in the last few releases, and most codebases still write tests the old way. That is fine until the old way costs you something: a goroutine leaked across tests, a defer os.Chdir that fails under parallel tests, a benchmark that measures setup. This article lists the newer features, shows them running on Go 1.25.6, and gives an order for adopting them in a codebase that was written years ago.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The features in one file

new_test.goGo
package main

import (
	"os"
	"path/filepath"
	"testing"
	"testing/synctest"
	"time"
)

func TestNewFeatures(t *testing.T) {
	// Go 1.24: t.Context is cancelled just before cleanup functions run.
	ctx := t.Context()
	t.Cleanup(func() { t.Logf("in cleanup the context is done: %v", ctx.Err() != nil) })

	// Go 1.24: t.Chdir changes directory and restores it.
	dir := t.TempDir()
	_ = os.WriteFile(filepath.Join(dir, "data.txt"), []byte("x"), 0o600)
	t.Chdir(dir)
	_, err := os.Stat("data.txt")
	t.Logf("relative file visible after t.Chdir: %v", err == nil)

	// Go 1.25: structured attributes attached to the test and printed in -json / -v output.
	t.Attr("owner", "payments")

	// Go 1.25: t.Output is a writer whose text is reported like t.Log.
	t.Output().Write([]byte("written through t.Output\n"))
}

// Go 1.25: virtual time for tests.
func TestSynctest(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now()
		time.Sleep(48 * time.Hour)
		t.Logf("slept two virtual days; elapsed=%v", time.Since(start))
	})
}

// Go 1.24: b.Loop. Go 1.22: for range over an integer.
func BenchmarkLoop(b *testing.B) {
	n := 0
	for b.Loop() {
		for range 10 {
			n++
		}
	}
}

func main() {}
Shell
go test -count=1 -v -run 'NewFeatures|Synctest' . 2>&1 | grep -vE '^(=== RUN|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_0-9]+\.go:[0-9]+: #    #'
echo
echo "# go.mod language version decides which are available"
go list -m -f 'module go version: {{.GoVersion}}' 
Text
    relative file visible after t.Chdir: true
=== ATTR  TestNewFeatures owner payments
    written through t.Output
    in cleanup the context is done: true
--- PASS: TestNewFeatures
    slept two virtual days; elapsed=48h0m0s
--- PASS: TestSynctest

# go.mod language version decides which are available
module go version: 1.25

Feature by feature

t.Context (Go 1.24). A context that is cancelled just before the test's cleanup functions run. Replace the old three-line idiom, ctx, cancel := context.WithCancel(context.Background()); t.Cleanup(cancel), with t.Context(). Goroutines started with it stop when the test ends. The lab confirms the order: the cleanup logged the context is done: true, so cleanup code that waits for goroutines to exit can rely on the cancellation having happened. Adopt first. It is a pure improvement, and the only change in behaviour is that a leaked goroutine now stops when its test finishes instead of running on, which is the point.

t.Chdir (Go 1.24). Changes the working directory for the test and restores it afterwards. After t.Chdir(dir) the lab opens data.txt by relative path and finds it. The old way, os.Chdir with a defer, is easy to get wrong and leaves later tests in the wrong directory when it is. Like t.Setenv, it cannot be used in a parallel test, because the directory is process-wide.

b.Loop (Go 1.24). The benchmark loop that excludes setup and defeats dead-code elimination; the effect is measured in the b.Loop note. Convert benchmarks when you next edit them, and delete the b.ResetTimer() calls that follow.

t.Attr (Go 1.25). Attaches a key and value to a test. In verbose output it prints as an ATTR line (=== ATTR TestNewFeatures owner payments), and in -json output as an event, so tools can read owner, ticket or environment metadata without parsing log text. Useful for a flaky test tracker or a team dashboard. The lab shows the verbose form.

t.Output (Go 1.25). A writer whose lines are reported as test output, like t.Log but usable where an io.Writer is needed: pass it to a logger, to exec.Cmd.Stdout, or to slog.NewTextHandler. Text written to it shows up attributed to the test, and not interleaved with other tests' output in parallel runs.

testing/synctest (Go 1.25, stable). Virtual time for code with sleeps and timers: the lab sleeps 48 hours inside synctest.Test and measures elapsed=48h0m0s instantly. Adopt it for the tests that currently wait, or that use a fake clock only because real time was too slow. The design and its limits are covered in stopping time based flakes.

Older additions that are still unused in many codebases:

FeatureSinceReplaces
t.Setenv1.17os.Setenv plus defer os.Setenv(old)
t.TempDir1.15ioutil.TempDir plus defer os.RemoveAll
t.Cleanup1.14Nested defers in helpers
-skip flag1.20Long -run negative regular expressions
Fuzzing1.18Hand-written random loops
for range n1.22for i := 0; i < n; i++
Per-iteration loop variables1.22tc := tc before t.Run
go test -json build events1.24Scraping compiler output
testing/slogtest1.21Ad hoc handler tests

The go line gates everything

Using a feature needs two things: a toolchain that has it, and a go line in go.mod that is at least the version that introduced it. The lab's last command prints module go version: 1.25, the line this module declares. Raise it with go get [email protected] or by editing the file, then run go mod tidy. Two effects follow that matter for an old codebase.

First, the go line also selects language semantics. At go 1.22 or later, each loop iteration has its own variable, and code that depended on the shared variable changes behaviour. This is rare and usually a bug fix, but run the whole suite with -race after the bump. Second, a library consumer's go.mod must be at least as new as yours if you publish a module: raising the line forces every user to upgrade. For an application, that is only your own toolchain and CI images; for a library, think twice and consider keeping go low and using build tags for the newest test features.

A plan for adopting them

  1. Upgrade the toolchain in CI and locally, then raise the go line one step. Run everything with -race -shuffle=on.
  2. Mechanical replacements first, with search and a small script: t.Setenv, t.TempDir, t.Cleanup, for range n, the removal of tc := tc. These are safe and reviewable.
  3. Adopt t.Context and t.Chdir where tests start goroutines or touch the working directory. Fix the tests that fail: they had leaks or hidden state.
  4. Convert benchmarks to b.Loop as you touch them. Re-run baselines, since numbers may change when setup stops being counted.
  5. Introduce synctest for the slowest timing tests. Measure the suite before and after.
  6. Add t.Attr and t.Output when a tool will use them, such as a flaky test tracker keyed on the owner.
  7. Write the conventions down so that new tests use the new idioms and reviewers know what to ask for.

Do one feature per pull request, with a note of what changed in behaviour, so a reviewer can tell a mechanical edit from a decision. If a bump surfaces many failures, take them one package at a time.

Two hazards when you bump the version

Behaviour changes hidden in the version. A few features are opt-in by the go line and change how existing code runs. The loop variable change is the famous one. A suite that passed before can fail after the bump because a test depended on the old sharing. Read the failure, fix the test, and be glad it surfaced.

Tool versions that lag. Linters, code generators and IDE plugins parse Go code and may not support the newest syntax or library calls. After the bump, run each in CI and check that none of them reports false errors on t.Context() or synctest. Pin the tool versions with a tool directive, and update them in the same pull request when needed.

What not to chase

Do not rewrite passing tests only to use a new method. A test that works and reads well gains nothing from t.Context. Do not adopt t.Attr before something consumes it. And do not bump the go line of a published library for a testing nicety. The release notes list each feature in context: Go 1.24 and Go 1.25, and the testing package documentation shows which version added each method. For related migrations, see moving off testify suites and the flags reference.

Taggedmigration
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.