Skip to content

Writing Tests with go test

Set up a module to run these examples locally

Section titled “Set up a module to run these examples locally”

Every example in this lesson is real, runnable Go — but go test needs a module on disk, which the in-browser playground cannot provide. Set one up once:

Terminal window
mkdir myapp && cd myapp
go mod init example.com/myapp

Save any _test.go file from this lesson next to a matching .go source file in that directory, then run go test ./.... Every command and every line of output shown below was produced this way, on Go 1.26.5.

Go’s test runner discovers tests through two rules enforced by the toolchain:

  1. File name ends in _test.go — the compiler excludes these files from normal builds.
  2. Function name starts with Test followed by a capital letter — TestAdd, TestParseURL, etc.

A minimal test file for a math.go source file lives in the same package and directory:

math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}

Run it with:

Terminal window
go test ./... # run all tests in all packages
go test -v ./... # verbose: print each test name and PASS/FAIL
go test -run TestAdd # run only tests whose name matches the regex

A test file can declare either package:

  • package math (same package as the code under test) — a “white-box” test. It can call unexported functions and read unexported fields, which is what most tests need.
  • package math_test (the package name plus _test) — a “black-box” test. It can only see what an outside importer sees, because it must import "example.com/myapp/math" like any other consumer. Go’s go build/go test machinery treats this as a special case: both packages are allowed to live in the same directory and both compile into the same test binary.

Use _test when you want the test to prove the public API works as documented — this is why the standard library’s own examples (fmt_test, strings_test) are overwhelmingly black-box. Use the same package when you need to exercise internal helpers directly, or when a black-box test would need an import cycle to reach the type under test.

Both record a failure, but they differ in whether the test continues:

FunctionMarks failureStops test?
t.ErrorfYesNo — test continues
t.FatalfYesYes — test stops immediately

Use t.Fatalf when continuing makes no sense — e.g. err != nil and the next line would dereference the nil result.

func TestDivide(t *testing.T) {
result, err := Divide(10, 0)
if err == nil {
t.Fatalf("expected an error for division by zero, got nil")
}
_ = result
}

t.Fatalf (and t.Fatal, t.FailNow, t.SkipNow) work by calling runtime.Goexit on the goroutine that is running the test. That only unwinds that goroutine — see “Reading AI-generated code” below for what happens when a helper goroutine calls it instead.

The idiomatic Go pattern for testing many inputs against the same function is a table-driven test: a slice of struct cases iterated with t.Run. Each sub-test gets its own name, failure message, and — with -v — its own line in the output.

fibonacci_test.go
package main
import "testing"
func TestFibonacci(t *testing.T) {
cases := []struct {
name string
n int
want int
}{
{"fib(0)", 0, 0},
{"fib(1)", 1, 1},
{"fib(5)", 5, 5},
{"fib(10)", 10, 55},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Fibonacci(tc.n)
if got != tc.want {
t.Errorf("Fibonacci(%d) = %d; want %d", tc.n, got, tc.want)
}
})
}
}

Run with -v to see each sub-test:

(Older code has a tc := tc line before t.Run; since Go 1.22 every iteration gets its own tc, so the copy is unnecessary and go fix removes it.)

Terminal window
go test -v -run TestFibonacci ./...
# --- PASS: TestFibonacci (0.00s)
# --- PASS: TestFibonacci/fib(0) (0.00s)
# --- PASS: TestFibonacci/fib(1) (0.00s)
# --- PASS: TestFibonacci/fib(5) (0.00s)
# --- PASS: TestFibonacci/fib(10) (0.00s)

This file has no func main — it cannot run through this course’s embedded Run button, which expects a main package it can execute directly. Paste it into https://go.dev/play/ instead: the real Go Playground detects a Test function and runs go test for you, printing exactly the -v output above.

t.Parallel(): the parent pauses, subtests resume after the loop

Section titled “t.Parallel(): the parent pauses, subtests resume after the loop”

Calling t.Parallel() inside a subtest does not start it running concurrently right away. It marks the subtest as parallel-eligible and pauses it; the parent t.Run loop keeps launching the next subtest without waiting. Only after the parent function returns from the loop do all the paused subtests resume and actually run concurrently with each other.

func TestParallelGroup(t *testing.T) {
tests := []string{"a", "b", "c"}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
t.Logf("running %s", name)
})
}
t.Log("parent reached end of loop")
}

Real output from go test -v -run TestParallelGroup .:

=== RUN TestParallelGroup
=== RUN TestParallelGroup/a
=== PAUSE TestParallelGroup/a
=== RUN TestParallelGroup/b
=== PAUSE TestParallelGroup/b
=== RUN TestParallelGroup/c
=== PAUSE TestParallelGroup/c
=== NAME TestParallelGroup
parallel_test.go:15: parent reached end of loop
=== CONT TestParallelGroup/a
parallel_test.go:12: running a
=== CONT TestParallelGroup/b
parallel_test.go:12: running b
=== CONT TestParallelGroup/c
parallel_test.go:12: running c
--- PASS: TestParallelGroup (0.00s)
--- PASS: TestParallelGroup/a (0.00s)
--- PASS: TestParallelGroup/b (0.00s)
--- PASS: TestParallelGroup/c (0.00s)
PASS

Notice the order: every subtest is RUN then immediately PAUSEd, then "parent reached end of loop" prints, and only after that do the three CONT lines appear — in this case back-to-back, but they genuinely run concurrently, so their internal log lines can interleave on a busier test. This is why cleanup that the parent does after the loop (closing a shared file, stopping a server) can race with parallel subtests that are still pending: they have not run yet, they are only queued.

Test helpers: t.Helper, t.Cleanup, t.TempDir, t.Setenv, t.Context, t.Chdir

Section titled “Test helpers: t.Helper, t.Cleanup, t.TempDir, t.Setenv, t.Context, t.Chdir”

t.Helper() marks the calling function as a test helper, so a failure reported from inside it is attributed to the caller’s line, not the helper’s own line:

func assertPositive(t *testing.T, n int) {
t.Helper()
if n <= 0 {
t.Fatalf("got %d, want positive", n)
}
}
func TestHelperFailure(t *testing.T) {
assertPositive(t, -1) // line 16
}

Real failure output — note the line number:

--- FAIL: TestHelperFailure (0.00s)
helper_test.go:16: got -1, want positive

Line 16 is the call site inside TestHelperFailure, not the t.Fatalf line inside assertPositive — that is t.Helper() doing its job. Without it, every failure from a shared assertion helper would point at the helper file, and you would have to guess which call site actually failed.

The rest of this family manage fixtures so you never write manual teardown code:

  • t.Cleanup(f) registers f to run after the test (and its subtests) finish, in last-added, first-called order — the reverse of defer within a single function, but the same idea across an entire test.
  • t.TempDir() returns a fresh temporary directory, automatically removed when the test completes. Each call returns a unique directory.
  • t.Setenv(key, value) sets an environment variable and restores the old value via t.Cleanup automatically.
  • t.Context() (Go 1.24) returns a context.Context that is canceled just before Cleanup functions run — hand it to any code under test that wants a context tied to the test’s lifetime.
  • t.Chdir(dir) (Go 1.24) changes the process’s working directory and restores it via Cleanup.

t.Setenv and t.Chdir both mutate process-wide state (environment variables and the working directory are not per-goroutine), so go doc is explicit that neither can be used in a parallel test or a test with a parallel ancestor — the toolchain rejects it at runtime with t.Setenv called after t.Parallel.

Real run of all five together:

=== RUN TestCleanupOrder
helper_test.go:22: test body
helper_test.go:21: cleanup 2
helper_test.go:20: cleanup 1
--- PASS: TestCleanupOrder (0.00s)
=== RUN TestTempDirAndSetenv
helper_test.go:31: tempdir=/var/folders/.../TestTempDirAndSetenv1802591350/001
--- PASS: TestTempDirAndSetenv (0.00s)
=== RUN TestContextAndChdir
--- PASS: TestContextAndChdir (0.00s)

TestCleanupOrder registers “cleanup 1” then “cleanup 2”; they print in the reverse order, confirming the last-added-first-called rule.

TestMain: setup and teardown for a whole package

Section titled “TestMain: setup and teardown for a whole package”

A file can define func TestMain(m *testing.M) once per package to wrap the entire test run — starting a shared resource before any test runs and releasing it after:

func TestMain(m *testing.M) {
fmt.Println("setup: acquiring shared resource")
code := m.Run()
fmt.Println("teardown: releasing shared resource")
os.Exit(code)
}

Real output:

setup: acquiring shared resource
=== RUN TestA
main_test.go:16: A
--- PASS: TestA (0.00s)
=== RUN TestB
main_test.go:17: B
--- PASS: TestB (0.00s)
PASS
teardown: releasing shared resource

m.Run() runs every test and returns the process exit code; you must call os.Exit(code) yourself — if TestMain returns without calling it, the test binary exits 0 regardless of failures. Reach for TestMain for expensive, package-wide setup (spinning up a test database container); prefer t.Cleanup for anything scoped to a single test.

FlagEffect
-count=1Disables the test result cache — forces a real re-run. go test caches a pass by default when nothing relevant changed.
-shuffle=onRandomizes the order tests and top-level benchmarks run in (per go help testflag), to surface hidden ordering dependencies between tests.
-failfastStops running new tests after the first failure — useful for a fast local feedback loop.
-run 'TestGroup/sub1'Filters both the top-level test name and, after the slash, the subtest name — each half is its own regular expression.
-timeout dPanics the whole test binary if it runs longer than d (default 10 minutes); -timeout 0 disables it.

Real, filtered run:

Terminal window
go test -v -run 'TestGroup/sub1' .
# === RUN TestGroup
# === RUN TestGroup/sub1
# sub_test.go:6: sub1
# --- PASS: TestGroup (0.00s)
# --- PASS: TestGroup/sub1 (0.00s)

Only sub1 ran — sub2 never started, because it did not match the pattern after the slash.

Comparing values: reflect.DeepEqual, go-cmp, testify, and errors.Is

Section titled “Comparing values: reflect.DeepEqual, go-cmp, testify, and errors.Is”

reflect.DeepEqual is the standard-library way to compare composite values, but it has sharp edges:

var nilSlice []int
emptySlice := []int{}
reflect.DeepEqual(nilSlice, emptySlice) // false — a nil slice and an empty slice are NOT DeepEqual

Verified locally: this evaluates to false. A test that builds want := []int{} but the function under test returns nil on the empty case will fail here even though both are “empty” for every practical purpose.

github.com/google/go-cmp/cmp (cmp.Diff, cmp.Equal) is the third-party tool most Go codebases reach for instead: it produces a readable diff on failure, and — unlike DeepEqual — it panics on unexported fields unless you opt in with cmpopts.IgnoreUnexported or similar. Verified locally against go-cmp v0.7.0:

type Point struct {
X, Y int
label string // unexported
}
cmp.Diff(Point{1, 2, "a"}, Point{1, 2, "b"})
panic: cannot handle unexported field at {cmpdemo.Point}.label:
"example.com/cmpdemo".Point
consider using cmpopts.EquateComparable to compare comparable Go types

That panic is a feature, not a bug: silently ignoring an unexported field (as DeepEqual effectively does not — it does compare unexported fields, which can make two logically-equal values compare unequal because of an internal cache field) is exactly the kind of false-negative test that go-cmp is designed to force you to make an explicit decision about. github.com/stretchr/testify (assert.Equal, require.Equal) takes a third approach — it uses ObjectsAreEqual (roughly reflect.DeepEqual plus a byte-slice fast path) and is popular mainly for its assertion helpers and failure-message formatting, not for a fundamentally different comparison algorithm.

For errors, never compare with == or DeepEqual — use errors.Is, which walks the Unwrap chain:

if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("got %v, want a not-exist error", err)
}

This passes even if err is a wrapped error (fmt.Errorf("open config: %w", os.ErrNotExist)), where err == os.ErrNotExist would be false.

A fuzz test seeds the corpus with example inputs, then Go’s mutation engine generates new inputs to try to break an invariant you assert:

reverse.go
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
reverse_test.go
func FuzzReverse(f *testing.F) {
f.Add("hello")
f.Add("")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
doubleRev := Reverse(rev)
if s != doubleRev {
t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, doubleRev, s)
}
})
}

go test -run FuzzReverse . runs only the two seed corpus entries added with f.Add, like a normal test. go test -fuzz FuzzReverse -fuzztime 2s . actually fuzzes — and on this exact function, it found a real, reproducible bug within two seconds:

fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 11 workers
fuzz: minimizing 47-byte failing input file
--- FAIL: FuzzReverse (0.01s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:12: Reverse(Reverse("\xb1")) = "�", want "\xb1"
Failing input written to testdata/fuzz/FuzzReverse/ffedc6f1114c9b4f
To re-run:
go test -run=FuzzReverse/ffedc6f1114c9b4f

The byte \xb1 alone is not valid UTF-8. Reverse converts the string with []rune(s), which replaces invalid bytes with U+FFFD (the replacement character) — so reversing twice loses information instead of round-tripping. This is exactly the class of bug fuzzing exists to find: a function that looks obviously correct for “normal” strings and silently breaks on malformed input an AI-written test suite would never think to hand-write. The failing case is saved under testdata/fuzz/; committing that file turns the discovery into a permanent regression test that also runs under plain go test.

An Example function doubles as documentation (shown on pkg.go.dev) and a real, runnable test: go test captures everything the function prints to stdout and compares it, line for line, against the // Output: comment.

func ExampleGreet() {
fmt.Println(Greet("Ada"))
// Output: Hello, Ada!
}

Real run:

=== RUN ExampleGreet
--- PASS: ExampleGreet (0.00s)

Delete or corrupt the // Output: line and go test stops treating the function as a test at all — it just compiles as an unused function — so a missing output comment fails silently rather than loudly. // Unordered output: accepts the lines in any order, useful for output produced from map iteration.

Go does not need a mocking framework for HTTP: net/http/httptest gives you a real server bound to a random local port, so you test the actual client code path instead of a hand-rolled fake.

func FetchUser(client *http.Client, baseURL string) (User, error) {
resp, err := client.Get(baseURL + "/user")
if err != nil {
return User{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return User{}, fmt.Errorf("unexpected status: %s", resp.Status)
}
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return User{}, err
}
return u, nil
}
func TestFetchUser(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"name":"Ada"}`))
}))
defer srv.Close()
u, err := FetchUser(srv.Client(), srv.URL)
if err != nil {
t.Fatalf("FetchUser: %v", err)
}
if u.Name != "Ada" {
t.Errorf("Name = %q, want Ada", u.Name)
}
}

Real run: --- PASS: TestFetchUser (0.00s). For testing a handler in isolation (no real network socket at all), use httptest.NewRecorder() with handler.ServeHTTP(recorder, request) instead — faster, and the standard choice for unit-testing a single handler rather than the client that calls it.

A golden file stores an expected output on disk and compares against it, which scales better than an inline string literal once the expected output is more than a few lines (a rendered report, a JSON payload, generated code).

var update = flag.Bool("update", false, "update golden files")
func TestRenderReport(t *testing.T) {
got := RenderReport([]string{"apples", "bananas"})
golden := filepath.Join("testdata", "report.golden")
if *update {
os.WriteFile(golden, []byte(got), 0o644)
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatal(err)
}
if got != string(want) {
t.Errorf("RenderReport() mismatch\ngot:\n%s\nwant:\n%s", got, want)
}
}

Verified locally: run once with go test -run TestRenderReport -update . to write testdata/report.golden from the current (reviewed!) output, then commit that file. Every subsequent go test run compares against it with no -update flag, and fails loudly the moment the output drifts:

=== RUN TestRenderReport
render_test.go:24: open testdata/report.golden: no such file or directory
--- FAIL: TestRenderReport (0.00s)

That is the real failure from the first run, before the golden file exists — exactly the signal you want: a missing or stale golden file fails the build instead of passing silently.

-coverprofile writes machine-readable coverage data; go tool cover -html turns it into a browsable report with covered lines in green and uncovered lines in red:

Terminal window
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

Verified locally: this produces coverage.html on disk (confirmed with ls -la), and go tool cover -func=coverage.out prints the same data as a per-function percentage table in the terminal, without needing a browser at all — useful in CI logs where nobody will open an HTML file.

Testing concurrent code with testing/synctest (Go 1.25+)

Section titled “Testing concurrent code with testing/synctest (Go 1.25+)”

Tests that involve goroutines, timers, or timeouts used to need real sleeps and were flaky. testing/synctest runs a function inside a bubble with virtualised time: time.Sleep and timers advance instantly once every goroutine in the bubble is blocked, and synctest.Wait() blocks until they all are.

// cache_test.go — a test file; run it with: go test ./...
package cache_test
import (
"testing"
"testing/synctest"
"time"
)
// Cache is a minimal TTL cache: entries expire lazily, checked on Get —
// there is no background goroutine driving expiry.
type Cache struct {
ttl time.Duration
items map[string]cacheItem
}
type cacheItem struct {
value string
expires time.Time
}
func NewCache(ttl time.Duration) *Cache {
return &Cache{ttl: ttl, items: make(map[string]cacheItem)}
}
func (c *Cache) Set(key, value string) {
c.items[key] = cacheItem{value: value, expires: time.Now().Add(c.ttl)}
}
func (c *Cache) Get(key string) (string, bool) {
item, ok := c.items[key]
if !ok || time.Now().After(item.expires) {
return "", false
}
return item.value, true
}
func TestCacheExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := NewCache(1 * time.Minute)
c.Set("k", "v")
time.Sleep(2 * time.Minute) // the fake clock jumps ahead once every goroutine in the bubble is blocked
if _, ok := c.Get("k"); ok {
t.Fatal("entry should have expired")
}
})
}

There is no synctest.Wait() call here: Get checks expiry synchronously against the fake clock, so there is no background goroutine to wait for. Wait() only matters when some other goroutine in the bubble needs to finish before you can assert on its effect.

Go 1.27 adds synctest.Sleep(d) (a sleep followed by Wait) and httptest.NewTestServer(), an httptest.Server on an in-memory fake network that works inside a bubble — so an HTTP client with a 30-second timeout can be tested in microseconds. Neither is available on this course’s local toolchain (Go 1.26.5) — the snippet above compiles and passes there as written; test synctest.Sleep/NewTestServer on go.dev or a local Go 1.27 install.

Test artifacts and attributes (Go 1.25–1.26)

Section titled “Test artifacts and attributes (Go 1.25–1.26)”
  • t.Attr(key, value) (1.25) attaches metadata that appears in -json output — handy for linking a test to an issue or a trace ID.
  • t.Output() (1.25) is an io.Writer you can hand to a logger so its lines interleave correctly with the test log.
  • t.ArtifactDir() (1.26) returns a directory for files the test wants to keep — screenshots, dumps, goldens. Run with go test -artifacts and the runner prints === ARTIFACTS TestName /path and preserves the directory under -outputdir.

Add -race to catch data races at test time — this is mandatory in CI:

Terminal window
go test -race -coverprofile=coverage.out -covermode=atomic ./...

go test -race switches coverage to -covermode=atomic automatically — go help testflag documents the default as set unless -race is enabled, in which case it becomes atomic. Pass -covermode=atomic explicitly only for clarity; it is not required.

go test also runs a small subset of go vet before every run; since Go 1.27 that subset includes stdversion, which fails the build when you call a standard-library API newer than the go line in your go.mod — so a module that says go 1.25 cannot silently depend on a 1.27 function.

flowchart TD
  A["go test ./..."] --> B["compile _test.go files into a test binary"]
  B --> C{"TestMain defined?"}
  C -->|yes| D["TestMain(m) calls m.Run()"]
  C -->|no| E["default runner calls each TestXxx in file order"]
  D --> E
  E --> F{"t.Parallel called?"}
  F -->|yes| G["subtest PAUSEs, next subtest starts"]
  F -->|no| H["subtest runs to completion"]
  G --> I["once the parent function returns, all paused subtests CONT and run concurrently"]
  H --> J["go vet subset + PASS/FAIL report"]
  I --> J
A single go test invocation, including where t.Parallel pauses and resumes work
What you getBenefitCost
Table-driven testsMany cases in one functionA table that grows too long gets hard to read
t.Run() subtestsIsolates failures, plays well with -run and parallelismNested output is harder to scan
t.Parallel()Runs tests concurrently, faster suiteEasy to race on shared state; process-wide helpers like t.Setenv/t.Chdir become unavailable
-race flagCatches race conditions automatically2-10x slower — use it in CI, not every local run
Fuzzing (*testing.F)Finds edge cases no one thought to write by handNeeds CPU time to be effective; a clean fuzz run for 2s proves far less than one for hours
Golden filesScales to large expected outputs, diffable in code reviewNeeds an -update flag and discipline to review the diff before committing it
  • t.Fatal() and t.Error() are the same — t.Fatal() stops the test immediately; t.Error() marks it failed but lets the remaining assertions run.
  • go test -cover at 100% means the code is correct — coverage only measures whether a line ran, not whether the behavior is correct — a test with no assertions can still reach 100%.
  • Test files must live in a separate package — Go supports both same-package tests (white-box) and a _test package suffix (black-box, e.g. cache_test) — pick whichever fits the case.
  • t.Helper() is optional — t.Helper() makes a failure report point at the caller instead of the helper function — call it in every helper.
  • t.Parallel() makes a subtest start running immediately — it pauses the subtest; all paused subtests only resume together once the parent’s t.Run loop returns.
  • A fuzz test that ran clean for a few seconds proved the function correct — it only proved no failing input was found in that time; Reverse above looked correct until fuzzing ran for two seconds.

💡 Real-world example

Go standard library — strconv/number_test.go stores its test cases in a parseInt64Tests table and iterates it in TestParseInt; adding a new edge case is one row. Open it at $(go env GOROOT)/src/strconv/number_test.go on any machine with Go installed. The standard library’s own strings package uses Example functions extensively (strings/example_test.go) — they are what you see rendered on pkg.go.dev/strings.

Generated tests compile far more often than they catch bugs. Check for these:

  • Is t.Parallel() called inside a for loop over a slice, on a Go version before 1.22, without a tc := tc capture line? If so every subtest shares the last loop value.
  • Does a spawned goroutine inside a test call t.Fatal, t.FailNow, or t.Fatalf? Those must be called from the test’s own goroutine — the docs are explicit that calling them from any other goroutine, even one the test started, is a bug.
  • Does a benchmark or test do expensive setup (building a large slice, opening a file) inside the loop that is supposed to measure or exercise only the operation under test?
  • Is a *testing.F’s f.Fuzz closure’s t.Fatal/error genuinely checking round-trip or invariant logic, or just re-asserting the exact input — a fuzz test that can never fail is not testing anything?
  • Does the test compare two structs with == or reflect.DeepEqual when one contains a time.Time or another struct where the comparison should logically ignore an internal field?
  • Is there a _test.go file whose package name does not match either the directory’s package or <package>_test — a common copy-paste mistake that fails to compile?

🐛 Spot the bug

func TestGroupPreGo122(t *testing.T) {
names := []string{"a", "b", "c"}
for _, name := range names {
t.Run(name, func(t *testing.T) {
t.Parallel()
if name == "" {
t.Fatal("empty name")
}
t.Logf("checked %s", name)
})
}
}
Show the answer

On Go versions before 1.22, name is a single variable reused by every loop iteration. Because t.Parallel() pauses each subtest before it reads name in the closure, all three subtests resume together after the loop has already finished — by then name holds "c", its final value, so every subtest logs checked c instead of its own name. Since Go 1.22 each iteration gets its own name, so this exact code is safe on 1.22+ — but reading it in an older codebase (or one that hasn’t raised its go.mod line) without recognizing the trap is exactly the bug this course exists to catch. The fix that works on every version is capturing explicitly: name := name as the first line inside the loop, before t.Run.

What file suffix does the Go toolchain require for test files?
When does "loop done" print relative to the two t.Log(name) lines?
func TestGroup(t *testing.T) {
    for _, name := range []string{"a", "b"} {
        t.Run(name, func(t *testing.T) {
            t.Parallel()
            t.Log(name)
        })
    }
    t.Log("loop done")
}
In a table-driven test, what does t.Run provide for each case?
What does a clean go test -race run (no races reported) actually tell you?
What line number does the failure report point to, and what one-line fix changes that?
func assertPositive(t *testing.T, n int) {
    if n <= 0 {
        t.Fatalf("got %d", n)
    }
}
func TestX(t *testing.T) {
    assertPositive(t, -1) // line 8
}
A FuzzReverse test ran for 2 seconds with go test -fuzz and found a failing input "\xb1" (an invalid UTF-8 byte) that breaks round-tripping through []rune(s). What does this demonstrate?