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.

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
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() {}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}}' 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.25Feature 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:
| Feature | Since | Replaces |
|---|---|---|
t.Setenv | 1.17 | os.Setenv plus defer os.Setenv(old) |
t.TempDir | 1.15 | ioutil.TempDir plus defer os.RemoveAll |
t.Cleanup | 1.14 | Nested defers in helpers |
-skip flag | 1.20 | Long -run negative regular expressions |
| Fuzzing | 1.18 | Hand-written random loops |
for range n | 1.22 | for i := 0; i < n; i++ |
| Per-iteration loop variables | 1.22 | tc := tc before t.Run |
go test -json build events | 1.24 | Scraping compiler output |
testing/slogtest | 1.21 | Ad 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
- Upgrade the toolchain in CI and locally, then raise the
goline one step. Run everything with-race -shuffle=on. - Mechanical replacements first, with search and a small script:
t.Setenv,t.TempDir,t.Cleanup,for range n, the removal oftc := tc. These are safe and reviewable. - Adopt
t.Contextandt.Chdirwhere tests start goroutines or touch the working directory. Fix the tests that fail: they had leaks or hidden state. - Convert benchmarks to
b.Loopas you touch them. Re-run baselines, since numbers may change when setup stops being counted. - Introduce
synctestfor the slowest timing tests. Measure the suite before and after. - Add
t.Attrandt.Outputwhen a tool will use them, such as a flaky test tracker keyed on the owner. - 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.


