How to Use Test Fixtures and testdata in Go

Store test data in a `testdata` directory and load it using `os.ReadFile` with `filepath.Join` for portable Go tests.

The fixture trap

You're writing a parser for a configuration format. The test needs a sample file. You paste the content into the test function as a multi-line string. The test passes. You add a second test with a slightly different payload. The file grows. You realize the payload is easier to read in its own file. You move it to config.sample. The test fails. The path is wrong. The build system ignores the file. You spend twenty minutes fighting the toolchain instead of verifying logic.

This happens when test data lives in the wrong place. Go has a built-in solution that keeps fixtures organized, portable, and out of your production binary. The solution is the testdata directory.

How testdata works

Go treats the testdata directory as a special zone. When you run go build, the compiler scans the package directory and skips everything inside testdata. The files never make it into your binary. When you run go test, the test runner copies the testdata directory alongside the test binary in a temporary location. The test runs with the current working directory set to the package directory. This means relative paths like testdata/input.txt resolve correctly.

The directory name must be exactly testdata. Rename it to fixtures or test_data and the magic stops. The compiler still skips it, but the test runner won't copy it. Your test fails at runtime with a file-not-found error. The convention is strict because the toolchain relies on this exact name.

testdata is a staging zone. Name it exactly or the magic vanishes.

Minimal example

Here's the standard pattern: locate the fixture relative to the package, read it, and handle errors.

package mypkg

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

func TestReadFixture(t *testing.T) {
	// filepath.Join ensures the path uses the correct separator for the OS.
	// This prevents issues on Windows where backslashes matter.
	path := filepath.Join("testdata", "input.txt")

	// os.ReadFile loads the entire file into memory.
	// The path resolves relative to the package directory.
	data, err := os.ReadFile(path)
	if err != nil {
		// t.Fatalf stops the test immediately and reports the error.
		// Use this for fatal setup failures like missing fixtures.
		t.Fatalf("read fixture: %v", err)
	}

	// Verify the content.
	if len(data) == 0 {
		t.Error("expected non-empty fixture")
	}
}

Relative paths work because the test runner sets the stage.

What happens at runtime

When you execute go test, the toolchain performs a sequence of steps. First, it compiles the test binary. The compiler includes all .go files in the package. It sees the testdata directory and excludes it. No fixture files end up in the binary. Second, the test runner creates a temporary directory for execution. It copies the test binary there. It also copies the entire testdata tree into that directory. The test binary runs with the current working directory set to the package root. This isolation means tests don't interfere with each other. It also means the test can run from any location on the filesystem.

The test runner handles cleanup automatically. When the test finishes, the temporary directory is removed. You don't need to manage file paths or cleanup logic for read-only fixtures.

Table-driven tests with fixtures

Real tests often cover multiple cases. Table-driven tests pair well with fixtures. Each case can reference a different file in testdata.

package mypkg

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

// TestCase defines a single test scenario with a fixture file and expected result.
type TestCase struct {
	name     string
	fixture  string
	expected int
}

func TestParseMultiple(t *testing.T) {
	// Define cases with fixture filenames.
	// The fixture path is relative to testdata.
	cases := []TestCase{
		{name: "simple", fixture: "simple.txt", expected: 42},
		{name: "empty", fixture: "empty.txt", expected: 0},
	}

	for _, tc := range cases {
		// t.Run creates a subtest for each case.
		// This allows running specific cases with -run flag.
		t.Run(tc.name, func(t *testing.T) {
			path := filepath.Join("testdata", tc.fixture)

			data, err := os.ReadFile(path)
			if err != nil {
				t.Fatalf("read fixture: %v", err)
			}

			result := parseData(data)
			if result != tc.expected {
				t.Errorf("expected %d, got %d", tc.expected, result)
			}
		})
	}
}

// parseData simulates the function under test.
func parseData(data []byte) int {
	// Implementation details omitted.
	return len(data)
}

Table-driven tests keep fixtures organized. Each case gets its own file and its own subtest.

Writable fixtures and temporary directories

Sometimes tests need to write files. For example, testing a function that generates a report. You can't write to testdata because it's read-only during the test run. The test runner copies testdata to a temp dir, but the copy is isolated. Writing there doesn't affect the source tree. However, relying on writable fixtures is fragile. Use t.TempDir instead.

package mypkg

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

func TestWriteReport(t *testing.T) {
	// t.TempDir creates a temporary directory.
	// The directory is automatically removed after the test finishes.
	tmpDir := t.TempDir()

	reportPath := filepath.Join(tmpDir, "report.txt")

	// Call the function that writes the report.
	err := generateReport(reportPath)
	if err != nil {
		t.Fatalf("generate report: %v", err)
	}

	// Verify the report was created.
	data, err := os.ReadFile(reportPath)
	if err != nil {
		t.Fatalf("read report: %v", err)
	}

	if len(data) == 0 {
		t.Error("expected non-empty report")
	}
}

// generateReport simulates writing a file.
func generateReport(path string) error {
	return os.WriteFile(path, []byte("report content"), 0644)
}

Use t.TempDir for writable files. It handles cleanup and isolation automatically.

Nested packages and testdata

Each package can have its own testdata directory. If you have a nested package structure, the testdata directory belongs to the package it sits in. The test runner copies testdata relative to the package being tested.

Consider a package mypkg/sub. The test file mypkg/sub/sub_test.go expects testdata at mypkg/sub/testdata. It does not look at mypkg/testdata. This isolation prevents tests in different packages from stepping on each other's fixtures.

The convention aside: keep testdata at the package root. If you need shared fixtures across packages, consider moving them to a dedicated test package or using a common utility function. Don't scatter testdata directories in random subfolders.

Pitfalls and errors

The directory name must be exactly testdata. Rename it and the test runner ignores it. The test fails with open testdata/input.txt: no such file or directory. This error is a runtime panic, not a compile error. The compiler doesn't know about testdata. It only knows about .go files.

You can nest directories inside testdata. testdata/subdir/file.txt works fine. The test runner copies the entire tree. Use filepath.Join to build paths safely. String concatenation like "testdata/" + filename breaks on Windows if the separator is wrong. filepath.Join normalizes separators for the current OS.

Don't put production code in testdata. The compiler skips it, but it's confusing for humans. Keep testdata for test artifacts only. If you need to test code that lives in testdata, move it to a test helper file.

Error handling in tests uses t.Fatalf for fatal errors. Missing fixtures are fatal. The test can't proceed. Use t.Errorf for assertion failures. The test can continue to check other conditions. This distinction keeps test output clean and actionable.

Rename the directory and the test breaks silently at runtime.

When to use testdata

Use testdata when you need to test file I/O or keep large fixtures out of the source code. Use //go:embed when your production code must ship with static assets like templates or config schemas. Use multi-line strings in the test file when the fixture is small and tightly coupled to the test logic. Use t.TempDir when the test needs to write files or modify fixtures during execution.

Embed for shipping. testdata for testing.

Where to go next