The Refund That Charged the Wrong Amount
A payments service exposes RefundAmount(orderCents, restockingPercent int) int. The rule: the customer gets back the order total minus the merchant's restocking fee, rounded down to whole cents. The first implementation writes the arithmetic the way it reads in the spec:
func RefundAmount(orderCents, pct int) int {
return (orderCents / 100) * (100 - pct)
}
RefundAmount(4999, 25) returns 3675 instead of 3749, because the division by 100 truncates before the percentage is applied and the error grows with the order size. Support caught it with a customer complaint, not with a test, because nothing in the repository executed this function automatically.
Go's answer is a file next to the code, suffixed _test.go:
package pricing
import "testing"
func TestRefundAmount(t *testing.T) {
got := RefundAmount(4999, 25) // $49.99 order, 25% restocking fee
want := 3749
if got != want {
t.Errorf("RefundAmount(4999, 25) = %d, want %d", got, want)
}
}
Four mechanical facts hold this together. The file lives in package pricing, the same package as the code under test, so unexported functions are reachable without exporting them for testing; writing package pricing_test instead is the alternative when you want to exercise only the exported API, which keeps the public surface honest. Test functions are named TestXxx, take a *testing.T, and return nothing — the compiler rejects any other shape. The _test.go suffix excludes the file from normal builds, so test code and its imports never ship in the production binary. And the entry point is the go tool: go test ./... compiles and runs every test in the module, go test ./pricing -run TestRefund -v narrows to one pattern with verbose output, and a non-zero exit code is what turns a failure into a red CI job.
Notice what the failure message contains. RefundAmount(4999, 25) = 3675, want 3749 restates the inputs, so the line stands alone when it scrolls past fifty other results. A message like got 3675, want 3749 forces the reader back to the source to learn which call failed. Make every failure self-contained: function, inputs, got, want.
The habit that matters more than any API is arrange, act, assert — and actually assert. A test that executes the function without inspecting a result compiles green while the feature stays broken; go vet has no opinion on whether your comparison exists. Every test ends with an explicit check routed through t.Errorf (report the failure, keep going) or t.Fatalf (report it, stop this test), a distinction that decides how much you learn from one CI run.
Fail Fast, or Count Every Broken Field?
You write TestCreateUser, which validates a request struct: name, email, password length, age minimum, timezone, accepted terms. The implementation returns the first error it finds, and your test copies that habit — t.Fatalf on the first mismatch. The first run reports name: required. You add a default name, re-run, and get email: required. Six iterations and six CI jobs later, the test finally passes. Each run told you one fact it could have told you six.
Switching the independent checks to t.Errorf reports every violated expectation in a single run:
func TestCreateUser_Validation(t *testing.T) {
u, err := CreateUser(Request{Name: "", Email: "not-an-email", Age: 14})
if err == nil {
t.Fatalf("CreateUser accepted an invalid request, user = %+v", u)
}
if !errors.Is(err, ErrNameRequired) {
t.Errorf("err = %v, want ErrNameRequired", err)
}
if !errors.Is(err, ErrBadEmail) {
t.Errorf("err = %v, want ErrBadEmail", err)
}
}
The rule that follows: use t.Errorf when the remaining assertions can still be evaluated, and t.Fatalf when they cannot — you are about to dereference a pointer that came back nil, or the parse failed so there is no struct to inspect. Fatal does not stop the whole run; it aborts the current test function through runtime.Goexit, so other tests still execute and the summary still lists every failed test.
One constraint catches people off guard: FailNow must be called from the goroutine running the test function. A t.Fatal inside a go func you spawned reports the failure but does not stop the test goroutine, so the test keeps running and may fail again for a second reason. From a worker goroutine, use t.Errorf — or better, send the result back and assert in the test goroutine, which is the shape you will need when testing concurrent code alongside the race detector.
Once a test covers several distinct scenarios, split them into subtests:
func TestSessionExpiry(t *testing.T) {
t.Run("fresh token still valid", func(t *testing.T) {
s := NewSession(time.Now())
if !s.Valid(time.Now().Add(time.Minute)) {
t.Error("session expired after one minute")
}
})
t.Run("expired token rejected", func(t *testing.T) {
s := NewSession(time.Now().Add(-2 * time.Hour))
if s.Valid(time.Now()) {
t.Error("session older than max age was accepted")
}
})
t.Run("clock skew of 30 seconds tolerated", func(t *testing.T) {
s := NewSession(time.Now().Add(30 * time.Second))
if !s.Valid(time.Now()) {
t.Error("small clock skew rejected a valid session")
}
})
}
Each t.Run gets its own pass/fail status, so the output reads --- FAIL: TestSession/expired token rejected and points at exactly one broken scenario instead of a function name shared by three. Subtests also compose with the command line: go test -run 'TestSession/expired' runs only the scenarios whose names match the slash-separated pattern. Keep names lowercase and hyphenated — they appear verbatim in CI logs. When the scenarios reduce to pure input-and-output pairs, the table-driven pattern writes them more compactly; t.Run is the machinery that pattern is built on.
Shared Assertions, Temp Files, and Tests You Cannot Run Today
The suite grows and three kinds of duplication appear: the same five-line comparison pasted into ten tests, a fixture file every test writes to ./testdata/out.csv, and one test that calls a live third-party API. Each has an established answer.
The comparison helper. Extract it, and mark it with t.Helper():
func wantString(t *testing.T, got, want, label string) {
t.Helper()
if got != want {
t.Errorf("%s = %q, want %q", label, got, want)
}
}
func TestRender_Title(t *testing.T) {
wantString(t, Render(&Post{Title: "Go"}), "<h1>Go</h1>", "Render")
}
Without t.Helper, a failure reports the line inside the helper — the same line for all ten tests — and the log never names the test that broke. With it, the reported file and line are the caller's, so each failure points at its own assertion. The helper still uses t.Errorf, so one broken expectation does not hide the others.
The shared output file. Two tests writing the same path interfere in ways that depend on run order, and go test -count=2 (running the suite twice to catch state leakage) makes it worse. t.TempDir() returns a unique directory for each test and removes it when the test finishes — including when the test fails with t.Fatalf:
func TestExport_WritesCSV(t *testing.T) {
path := filepath.Join(t.TempDir(), "out.csv")
if err := Export(path, []Row{{"a", "b"}}); err != nil {
t.Fatalf("Export: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
wantString(t, string(data), "a,b\n", "csv contents")
}
A fixed /tmp/out.csv would survive the run, leak state into the next one, and collide the moment tests run in parallel or on two CI workers sharing a machine.
Resources and refusal to run. Anything the test starts — a server, a worker pool, a ticker — gets registered for teardown with t.Cleanup, which runs in last-registered order even after a Fatal:
func startWorker(t *testing.T) *Worker {
t.Helper()
w := NewWorker()
w.Start()
t.Cleanup(w.Stop) // a leaked goroutine here stalls the whole package later
return w
}
That line is the test-side counterpart of finding goroutine leaks: a worker nobody stops keeps the test binary alive after the results print, and the CI job hangs until it times out.
Finally, the tests that cannot run everywhere. t.Skip records the test as skipped rather than failed, with a message that says why: gate a live-API test behind testing.Short() so go test -short ./... is the fast pre-commit loop while the nightly job runs everything. A skip without a reason is indistinguishable from a silently broken test. By this point the suite runs green in seconds, tells you which scenario broke on the first failure, and cleans up after itself — the questions coverage and benchmarks answer next: what it does not exercise, and how fast the code under it really is.
Quiz
1. A test validates four independent fields of a parsed request with t.Fatalf, and the developer sees exactly one failure per CI run until everything passes. What change reports the most information per run?
2. Ten tests call a shared assertEqual helper, and every failure report points at the same line inside that helper. Which call fixes the report?
3. Two tests write their output to ./fixtures/out.csv, and the suite passes alone but fails when run with -count=2 or on a shared CI worker. What is the fix?
4. You want CI to run only the 'expired token rejected' scenario of TestSessionExpiry. Which command does it?
Flashcards
Question
What three things must be true for the testing package to run a function as a test?
Click to reveal answer
Answer
It must live in a file ending _test.go, be named with the Test prefix, and take a single *testing.T parameter with no return values. The _test.go suffix is also what keeps the function and its imports out of production builds.
Question
When does a test call t.Fatalf instead of t.Errorf, and what does Fatal actually stop?
Click to reveal answer
Answer
When the remaining assertions cannot be evaluated — the parse failed, the pointer is nil, the function under test returned nothing usable. Fatal reports the failure and calls runtime.Goexit on the current test function only; other tests in the package keep running and the summary still lists them.
Question
What breaks when t.Fatal is called from a goroutine the test spawned?
Click to reveal answer
Answer
FailNow must run on the goroutine executing the test function. From a spawned goroutine it only records the failure and exits that goroutine — the test itself keeps running and may produce a second, confusing failure. Send results back to the test goroutine and assert there, or use t.Errorf.
Question
Why does t.TempDir beat a fixed path like /tmp/testdata?
Click to reveal answer
Answer
It returns a unique directory per test, removes it when the test ends — including after t.Fatalf — and keeps parallel or repeated runs (-count=2) from reading each other's leftovers. A fixed path leaks state across runs and collides on shared CI machines.
Question
What does subtest naming buy you beyond readability?
Click to reveal answer
Answer
Each t.Run name becomes a path segment, so go test -run 'TestSession/expired' executes one scenario in seconds and CI failures read as --- FAIL: TestSession/expired token rejected instead of a whole-function name. Names are the selection interface for the test binary.
Revision Notes
Key Takeaways
- 1. Tests live in _test.go files beside the code, are excluded from production builds, and run through go test with a CI-meaningful exit code
- 2. t.Errorf accumulates independent failures in one run; t.Fatalf aborts a test whose remaining assertions have nothing to inspect
- 3. t.Fatal is only valid from the test's own goroutine — spawned goroutines must report with t.Errorf and return results for assertion
- 4. t.Helper, t.TempDir, and t.Cleanup remove the three common sources of suite rot: ambiguous failure lines, shared fixture paths, and leaked resources
- 5. Subtest names double as a command-line interface: go test -run 'TestName/subtest' runs exactly one scenario
Interview Tips
- • "Show me how you'd test this function" — write the _test.go file, state arrange-act-assert out loud, and make the failure message restate inputs so the interviewer sees you have run tests before
- • "t.Error or t.Fatal?" — answer with the rule, not the API: independent checks accumulate with Error, Fatal only when continuing would inspect a value that does not exist
- • "How do you test something that needs a file or a server?" — t.TempDir for state, t.Cleanup for teardown, t.Skip with a reason when the dependency is unavailable
- • "How do you keep CI fast?" — gate live dependencies behind testing.Short(), use -run to iterate on one subtest locally, and reserve -race and -count=2 for the full pipeline
Cheat Sheet
Unit Tests with the testing Package
Choose the reporting method:
| Method | Effect | Use when |
|---|---|---|
| t.Errorf | report failure, keep running | independent checks, each can be evaluated |
| t.Fatalf | report failure, Goexit this test | continuing would read a nil or meaningless value |
| t.Skip | mark skipped with a reason | environment missing, running with -short |
| t.Helper | fix reported file:line to the caller | assertion lives in a shared helper |
| t.Cleanup | run teardown LIFO, even after Fatal | stop servers, workers, tickers a test started |
| t.Run | nested subtest with its own status | several scenarios in one logical test |
Commands:
go test ./... # run every test in the module
go test ./pricing -v # one package, verbose
go test -run 'TestSession/expired' # one subtest
go test -short ./... # skip tests gated on testing.Short()
go test -race -count=2 ./... # detect races, catch state leakage
go test -cover ./... # statement coverage, see go-coverage-benchmarks
Test file rules: file ends in _test.go; package foo for white-box access to unexported identifiers, package foo_test for the public API only; test funcs are TestXxx(*testing.T) with no results; failure messages restate function, inputs, got, and want.
Code template:
func TestRefundAmount(t *testing.T) {
t.Run("no restocking fee", func(t *testing.T) {
got := RefundAmount(1000, 0)
if got != 1000 {
t.Errorf("RefundAmount(1000, 0) = %d, want 1000", got)
}
})
}