Tests that depend on file input or produce file output need a place to keep their data. Go has a strong convention for this: a testdata/ directory next to the test file, plus a handful of standard library helpers for temporary directories, environment variables, and golden-file comparisons. This chapter covers the testdata/ convention, reading fixtures with os.ReadFile, the golden file pattern with an -update flag, and the t.TempDir() and t.Setenv() helpers for tests that need a clean scratch space.
The Go toolchain has one special-cased directory name: testdata. When go build walks a package looking for sources, it skips any directory named testdata. That makes it the canonical place to keep test inputs without confusing the compiler, the documentation tools, or go mod tidy.
The rules are simple. testdata lives inside the package directory. It can contain any files or subdirectories you want, in any format: JSON, CSV, binary, even nested folders. The compiler ignores it, but go test still has access to it because it runs in the package directory.
When go test runs, the working directory is the package directory. That means a test can read fixtures with a relative path:
os.ReadFile returns the full file contents as []byte. For larger files you'd want os.Open and a streaming reader, but most fixtures are small enough that reading the whole thing is fine.
Cost: Each test that calls os.ReadFile hits the filesystem. For a small fixture this is a few microseconds, but if hundreds of tests read the same file in a tight loop, consider reading it once in TestMain and sharing the bytes. The convention is to keep fixtures small enough that the cost doesn't matter.
A handler example. Suppose the shop has a CSV import for products and you want a test that parses a real-looking file. The fixture lives in testdata/products.csv:
And the test:
The path is literally testdata/products.csv, relative to the package directory. You don't need filepath.Join or any environment lookup. Second, this fixture is easy to read and modify by hand. Anyone reviewing the test can open the CSV file and see what the data looks like.
A golden file is a checked-in fixture that captures the expected output of a function. The test runs the function, compares the result to the golden file, and fails if they differ. When the output legitimately changes (you fixed a bug, added a field, changed the format), you regenerate the golden file with a special flag.
This pattern shines for tests where the output is complex: JSON reports, generated code, formatted text, snapshots of struct trees. Hand-writing the expected value as a string literal in the test code becomes painful past ten lines. A golden file is just a file on disk, with line-by-line diffs in code review.
Here's the shape. A function generates a JSON report of an order summary:
The golden file at testdata/order-summary.golden:
And the test that compares against it:
The flow:
go test normally. The test reads testdata/order-summary.golden and compares.Summarize (add a field, change formatting).go test -update. The test writes the new output to the golden file.go test again to confirm the test now passes against the updated fixture.The -update flag is the key. Without it, you'd have to either edit the golden file by hand (error-prone) or write throwaway code to dump the new output. With it, the regeneration is one command, and the diff lives in version control alongside the source change.
The flag is global, so all golden tests in the package share it. Running go test -update rewrites every golden file the test suite touches. That's usually what you want; if it isn't, you can narrow with -run: go test -update -run TestSummaryJSON.
When a test needs to write files (not just read them), t.TempDir() gives you a fresh directory that exists for the lifetime of the test and is removed automatically when the test finishes. No manual cleanup, no leftover files, no race conditions between parallel tests.
t.TempDir() returns a path like /tmp/TestWriteReceipt1234567890/001. The directory is real (not in-memory), so any os package call works against it. When the test ends, whether it passed, failed, or panicked, the directory and everything in it is removed.
Multiple calls to t.TempDir() from the same test return different directories, which is useful when a test needs both a "source" and a "destination" directory:
The two directories are independent, both auto-cleaned. Use filepath.Join instead of string concatenation to keep the test portable across operating systems; Windows uses backslashes, Unix uses forward slashes, and filepath.Join picks the right one.
Cost: t.TempDir() creates a real directory on disk and walks it during cleanup. For tests that don't actually need a filesystem, an in-memory bytes.Buffer or io/fs.FS mock is cheaper. Use TempDir when the code under test genuinely touches the filesystem.
A more realistic example: a function that writes a receipt and your test verifies both the file's contents and that no extra files were created.
os.ReadDir on a temp directory you control is reliable. There's no risk of a stray file from a previous test or another process; the directory was created fresh for this test.
t.Setenv(key, value) sets an environment variable for the duration of the test and restores the previous value (or unsets it) on cleanup. This is the safe way to test code that reads from os.Getenv. Before t.Setenv (added in Go 1.17), tests had to use os.Setenv plus a t.Cleanup block to restore state, and the boilerplate was error-prone.
The variable lives for the duration of t.Run because Setenv is scoped to the test it's called from. After the subtest finishes, the previous value is restored. If SHOP_CURRENCY wasn't set in the parent environment, it's unset again.
There's one important constraint: t.Setenv panics if the test (or any parent) called t.Parallel(). Environment variables are process-global, so concurrent tests writing to them would race. The package designers chose to make this fatal rather than allow flaky tests. If you need parallel tests with isolated environments, pass the value as a function parameter instead of reading from os.Getenv.
Tests of the inner currencySymbol function can run in parallel because they pass the value directly. The outer wrapper still uses t.Setenv for one or two end-to-end checks. This split, pure logic that's easy to test with a thin shell that reads from the environment, is a standard Go idiom.
Tests should not depend on each other's state. Three sources of cross-test contamination matter:
t.TempDir() so each test gets a fresh directory.t.Setenv so each test sees the value it set.The first two have built-in helpers. The third requires discipline:
This is fine for a small test file. For anything larger, the pain of remembering to reset becomes a bug magnet. The Go idiom is to make state injectable:
Each test creates its own CartStore. There's nothing to reset, nothing to leak, and the tests can use t.Parallel() without contention.
The pattern on the right (inject the state) scales. The pattern on the left (manual reset) works for a handful of tests and breaks when the suite grows. If you find yourself writing pkgVar = nil in more than a few tests, that's a signal to refactor.
Updating a golden file is a deliberate act. The point of the test is to catch unintended changes, so a sloppy go test -update defeats the purpose. Three habits keep this honest.
First, read the diff. After running go test -update, run git diff testdata/ and look at every changed line. If a change surprises you, the bug is in the source, not the fixture.
Second, scope the update with -run. Running go test -update -run TestSummaryJSON regenerates only that test's golden file. Running go test -update ./... regenerates every golden file in the whole repo, which is rarely what you want.
Third, commit the source change and the golden update in the same commit (or in clearly-linked commits). A reviewer who sees a code change should see the corresponding golden file change next to it, with both explained in the commit message.
A common refinement is to wrap the read/compare/update logic in a helper:
The helper does the file I/O, the comparison, and the update logic in one place. Tests stay short and the update behavior is consistent across the package. The t.Helper() call tells the test framework to report the failure at the caller's line, not inside the helper, which makes test failures easier to read.
10 quizzes