Go Test Data: Generated JSON Fixtures for Table-Driven Tests
Go test data is usually an afterthought. Go's testing conventions are
excellent plumbing: a testdata/ directory the go tool ignores,
table-driven tests that run each row as a t.Run subtest, and golden
files for comparing large outputs. None of them say where the rows in
the table come from. So most Go packages end up with a
[]struct{...} literal of four or five hand-written cases, all named
"alice" and "bob", and the edge cases arrive one at a time, after the
bugs they would have caught.
This post builds the alternative: a JSON fixture file generated once
from a seeded template, committed under testdata/, and decoded into
the test table. Valid rows and deliberately invalid ones share one
table, every row carries its expected error, the same file drives a
golden-file test, and any variant of the data can be rebuilt from its
seed.
JsonFabrica's part is small and happens outside go test. It is a
JSON-over-HTTP API. There is no Go SDK and no JsonFabrica go generate
tool: a shell script you own sends the template and a seed with curl,
and writes the response to testdata/. A //go:generate directive can
run that script for convenience, but the tests only ever read a file.
Where Go test data usually comes from
The standard table-driven test looks like this:
tests := []struct {
name string
in Signup
wantErr error
}{
{"valid", Signup{Name: "Alice", Email: "[email protected]", Age: 30}, nil},
{"no name", Signup{Email: "[email protected]", Age: 30}, ErrMissingName},
}
It is readable and fast, but it has three limits:
- It stays small. Nobody hand-writes forty signups, so the table tests four, and they all look alike: short ASCII names, one email domain, round ages.
- Boundaries get skipped. Age 17 and age 18 are the cases that
catch an off-by-one in
< 18, and they are exactly the rows that don't get written until someone files the bug. - The data can't be shared. A struct literal in
signup_test.gois invisible to the handler tests in another package, and to anything that isn't Go.
The usual next step, calling a random-data library inside the test, fixes volume but loses reproducibility unless every caller seeds it the same way, and the data never exists anywhere you can review it.
JSON fixtures in the Go testdata directory
The approach here splits generation from testing:
- Generate offline. A script sends a template and a fixed seed to
the JsonFabrica API and writes
testdata/signups.json. You run it when the template changes, review the diff, and commit the file. - Read in tests. The test decodes the committed file with
encoding/json.go testneeds no API key and makes no network calls, and every machine reads the same bytes.
testdata/ is the natural home for that file. As go help packages
documents, the go tool ignores directories named testdata, so nothing
in it is ever compiled or matched by ./.... And because go test
runs each test binary in its package's source directory, a test opens
testdata/signups.json with a plain relative path.
The layout for the rest of the post:
go.mod # module example.com/accounts, go 1.22
scripts/
regen-fixtures.sh # calls the API; run by hand, never by go test
signup/
signup.go # code under test
generate.go # the //go:generate directive
signup_test.go # table-driven and golden tests
fuzz_test.go # a fuzz target seeded from the fixture
testdata/
signups.tmpl # the template (committed)
signups.json # generated fixture (committed)
export.golden # expected CSV export (committed)
The code under test validates new-account signups with four rules: the name must be non-empty, the email must parse as a bare address, and the age must be non-negative and at least 18.
// Package signup validates new-account requests.
package signup
import (
"encoding/csv"
"errors"
"io"
"net/mail"
"strconv"
"strings"
)
// Signup is one new-account request.
type Signup struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
Country string `json:"country"`
}
var (
ErrMissingName = errors.New("signup: missing name")
ErrInvalidEmail = errors.New("signup: invalid email")
ErrNegativeAge = errors.New("signup: negative age")
ErrUnderage = errors.New("signup: under 18")
)
// Validate returns the first rule s breaks, or nil.
func Validate(s Signup) error {
switch {
case strings.TrimSpace(s.Name) == "":
return ErrMissingName
case !validEmail(s.Email):
return ErrInvalidEmail
case s.Age < 0:
return ErrNegativeAge
case s.Age < 18:
return ErrUnderage
}
return nil
}
func validEmail(s string) bool {
addr, err := mail.ParseAddress(s)
return err == nil && addr.Address == s
}
(WriteCSV, the function the golden test covers, lives in the same
file and appears further down.)
A template for Go test data with planted invalid rows
One template produces the whole table. Each row has a stable ID, a
short case label, the input that will be decoded into a Signup, and
wantErr, the error the row should produce:
{
"rows": [
<for(i, 1, getParam('rows', 12))><if(getVar('i') > 1)>,<endIf>
{
"id": "row-<appendBefore('0', getVar('i'), 2)>",
"case": <if(getVar('i') == 1)>"missing-name"
<elseIf(getVar('i') == 2)>"invalid-email"
<elseIf(getVar('i') == 3)>"negative-age"
<elseIf(getVar('i') == 4)>"age-17"
<elseIf(getVar('i') == 5)>"age-18"
<else>"valid"<endIf>,
"input": {
"name": <if(getVar('i') == 1)>""<else>"<getRandomFullName()>"<endIf>,
"email": <if(getVar('i') == 2)>
"<toLowerCase(getRandomName())>.example.com"
<else>"<getRandomEmail('example.com')>"<endIf>,
"age": <if(getVar('i') == 3)>-1
<elseIf(getVar('i') == 4)>17
<elseIf(getVar('i') == 5)>18
<else><getRandomNumber(19, 90)><endIf>,
"country": "<getRandomCountry()>"
},
"wantErr": <if(getVar('i') == 1)>"missing_name"
<elseIf(getVar('i') == 2)>"invalid_email"
<elseIf(getVar('i') == 3)>"negative_age"
<elseIf(getVar('i') == 4)>"underage"
<else>""<endIf>
}<end_for>
]
}
How it's put together:
- The row count is a parameter.
getParamreads it once, as the loop bound, with a default of 12. - IDs come from the loop index.
appendBeforepadsito two digits, so the rows are alwaysrow-01torow-12. Stable IDs are what make stable subtest names, as you'll see below. - The planted rows sit at fixed indexes. Rows 1 to 4 each break
exactly one rule: an empty name, an email with no
@(a lowercased first name fromgetRandomName, then.example.com), an age of -1, and an age of 17. Row 5 is the other side of the boundary: age 18, which must be accepted. Rows 6 and up are ordinary valid signups withgetRandomEmailaddresses and ages from 19 to 90. - Each planted row is otherwise valid. The negative-age row still
has a real name and a real email, so
Validatecan only reject it for one reason. If a refactor reorders the checks, the test notices. - Every row carries its outcome.
wantErris a short code, or the empty string for a valid row. The Go test maps codes to sentinel errors, so the fixture doesn't need to know Go error text.
Template expressions support comparisons (==, !=, <, <=, >,
>=) and &&/||, but no arithmetic, which is why the planted rows
are picked by index rather than by a counter. for bounds are
inclusive, the loop variable is read with getVar('i'), and the guard
<if(getVar('i') > 1)>,<endIf> puts a comma before every row except
the first. The control flow docs cover for and
if/elseIf/else. For more ideas about what to plant, such as
Unicode names, very long strings, and nulls, see
boundary-value and edge-case test data.
Rendered with seed 20261026 and the default 12 rows, the file the
regen script writes starts like this (trimmed to the first five rows):
{
"seed": 20261026,
"rows": [
{
"id": "row-01",
"case": "missing-name",
"input": {
"name": "",
"email": "[email protected]",
"age": 36,
"country": "Austria"
},
"wantErr": "missing_name"
},
{
"id": "row-02",
"case": "invalid-email",
"input": {
"name": "Adella Langham",
"email": "tristan.example.com",
"age": 29,
"country": "Japan"
},
"wantErr": "invalid_email"
},
{
"id": "row-03",
"case": "negative-age",
"input": {
"name": "Belin Fenson",
"email": "[email protected]",
"age": -1,
"country": "Austria"
},
"wantErr": "negative_age"
},
{
"id": "row-04",
"case": "age-17",
"input": {
"name": "Danny Cameron",
"email": "[email protected]",
"age": 17,
"country": "Ireland"
},
"wantErr": "underage"
},
{
"id": "row-05",
"case": "age-18",
"input": {
"name": "Delilah Blackfield",
"email": "[email protected]",
"age": 18,
"country": "Sweden"
},
"wantErr": ""
}
]
}
Regenerating test fixtures with go generate
The regen script is the only code that talks to JsonFabrica. It reads
the template, sends it to
POST /v1/templates/generate, and
writes the result. That endpoint renders a raw template body without
storing it, so the template stays a file in your repo next to the data
it produces.
#!/usr/bin/env bash
# Regenerates signup/testdata/signups.json from signups.tmpl by calling
# the JsonFabrica API. Run it by hand (or via go generate), review the
# diff, and commit the result. go test never runs this script.
set -euo pipefail
seed="${SEED:-20261026}" # SEED=random lets the API pick one
rows="${ROWS:-12}"
api="${JSONFABRICA_URL:-https://api.jsonfabrica.com}"
dir="$(cd "$(dirname "$0")/../signup/testdata" && pwd)"
request="$(jq -n \
--rawfile body "$dir/signups.tmpl" \
--arg seed "$seed" \
--argjson rows "$rows" \
'{body: $body, params: {rows: $rows}}
+ if $seed == "random" then {} else {seed: ($seed | tonumber)} end')"
response="$(curl -sS --fail-with-body \
-H "Authorization: Bearer $JSONFABRICA_API_KEY" \
-H "Content-Type: application/json" \
-d "$request" \
"$api/v1/templates/generate")" \
|| { echo "$response" >&2; exit 1; }
# Refuse to write a file nobody can regenerate.
echo "$response" | jq -e --arg seed "$seed" \
'$seed == "random" or .meta.seed == ($seed | tonumber)' >/dev/null \
|| { echo "API used a different seed than $seed" >&2; exit 1; }
echo "$response" | jq '{seed: .meta.seed} + .data' >"$dir/signups.json"
used="$(jq .seed "$dir/signups.json")"
echo "wrote $dir/signups.json (seed $used, $rows rows)"
The details that matter:
- The response is
{ data, meta }.datais the generated document.meta.seedis the seed the API used: the one you sent, or the one it picked if you sent none. - The seed is written into the file. The top-level
seedkey means the fixture records how to rebuild itself.meta.generatedAtis left out on purpose, because it changes on every call and would make every regeneration show a diff. - Same template, seed, and params give the same file. Running the
script twice produces no diff. Raising
ROWSappends rows and leaves the existing ones untouched, because the loop bound doesn't consume randomness. Editing the template shifts the random draws, so commit the regenerated file in the same change as the template. - API errors stop the script.
--fail-with-body(curl 7.76 or later) makes curl exit non-zero on a 4xx or 5xx and still print the error body, such as anINVALID_TEMPLATEorGENERATION_FAILEDcode.--rawfileneeds jq 1.6 or later.
To make the script discoverable from Go, add a directive to a non-test file in the package:
package signup
// The directive below runs our own script, which calls the JsonFabrica
// HTTP API. Only `go generate` runs it; go build and go test never do.
//go:generate bash ../scripts/regen-fixtures.sh
go generate ./signup then regenerates the fixture. Keep in mind how
go generate behaves:
- It never runs implicitly. Only an explicit
go generateruns the directive (written//go:generate, no space);go buildandgo testignore it. - It runs in the package directory, without a shell. Hence the
../scripts/path; pipes and redirects belong in the script. Environment variables pass through, soSEED=... go generate ./signupworks. - Watch for
go generate ./...in CI. A "generated code is up to date" step would call the API and need a key. Narrow that step withgo generate -run(a regular expression that selects directives), or drop the directive and call the script directly.
Without bash and jq (on Windows, say), the same logic fits in a small
Go main package run with //go:generate go run ../internal/regenfixtures.
Golang table-driven tests from the fixture file
The test file decodes the fixture into a struct that mirrors it, and turns each row into a subtest:
package signup
import (
"bytes"
"encoding/json"
"errors"
"flag"
"os"
"path/filepath"
"testing"
)
var update = flag.Bool("update", false, "rewrite testdata/*.golden")
type fixture struct {
Seed int64 `json:"seed"`
Rows []fixtureRow `json:"rows"`
}
type fixtureRow struct {
ID string `json:"id"`
Case string `json:"case"`
Input Signup `json:"input"`
WantErr string `json:"wantErr"`
}
// wantErrs maps the template's wantErr codes to sentinel errors.
var wantErrs = map[string]error{
"": nil,
"missing_name": ErrMissingName,
"invalid_email": ErrInvalidEmail,
"negative_age": ErrNegativeAge,
"underage": ErrUnderage,
}
func loadFixture(tb testing.TB) fixture {
tb.Helper()
f, err := os.Open(filepath.Join("testdata", "signups.json"))
if err != nil {
tb.Fatal(err)
}
defer f.Close()
dec := json.NewDecoder(f)
dec.DisallowUnknownFields() // a renamed template field fails loudly
var fx fixture
if err := dec.Decode(&fx); err != nil {
tb.Fatalf("decode testdata/signups.json: %v", err)
}
return fx
}
func TestValidate(t *testing.T) {
fx := loadFixture(t)
t.Logf("testdata/signups.json: seed %d, %d rows", fx.Seed, len(fx.Rows))
for _, row := range fx.Rows {
t.Run(row.ID+"-"+row.Case, func(t *testing.T) {
t.Parallel() // safe: row is per-iteration as of Go 1.22
want, ok := wantErrs[row.WantErr]
if !ok {
t.Fatalf("fixture has unknown wantErr %q", row.WantErr)
}
if err := Validate(row.Input); !errors.Is(err, want) {
t.Errorf("Validate(%+v) = %v, want %v", row.Input, err, want)
}
})
}
}
func TestFixtureCoversEveryError(t *testing.T) {
seen := map[string]bool{}
for _, row := range loadFixture(t).Rows {
seen[row.WantErr] = true
}
for code := range wantErrs {
if !seen[code] {
t.Errorf("no fixture row expects %q", code)
}
}
}
What each piece is doing:
loadFixturetakestesting.TB. That lets the table tests, the golden test, and the fuzz target below share it.tb.Helper()makes a decode failure point at the test that called it.DisallowUnknownFieldscatches drift. If someone renameswantErrin the template and regenerates, decoding fails with an error naming the field, instead of every row silently expecting no error.- Rows must fail for the right reason.
errors.Is(err, want)checks the specific sentinel, anderrors.Is(nil, nil)is true, so valid rows need no special case. A test that only checkederr != nilwould pass if the age check fired on the missing-name row. TestFixtureCoversEveryErrorguards the planted rows. If a template edit drops one, this test says which error has lost its coverage.
Subtest names that stay stable
The subtest name is the row ID plus its case label, so go test -v
reads like a list of cases (shown with the PAUSE and CONT lines
that parallel subtests add trimmed out):
=== RUN TestValidate/row-01-missing-name
=== RUN TestValidate/row-02-invalid-email
=== RUN TestValidate/row-03-negative-age
=== RUN TestValidate/row-04-age-17
=== RUN TestValidate/row-05-age-18
=== RUN TestValidate/row-06-valid
Because the IDs come from the loop index, the names survive a
regeneration, and -run selects the same row every time.
go test ./signup -run 'TestValidate/age-18' runs only row 5: -run
splits its pattern on / and matches each part against one level of
test name.
Avoid naming subtests after generated values such as row.Input.Name:
they change with the data, and go test rewrites them (spaces become
underscores, duplicates get a #01 suffix).
t.Parallel and the Go 1.22 loop variable change
In a module whose go.mod declares go 1.22 or later, each loop
iteration gets its own row, so every parallel subtest captures its
own row. In older modules, parallel subtests pause until the loop ends
and all see the last row, so add row := row before t.Run.
What a failure looks like
To check that the boundary rows earn their place, change s.Age < 18
to s.Age <= 18 in Validate and run
go test ./signup -run TestValidate. The -run flag keeps
TestWriteCSVGolden out of the output. It would fail too, since row
5's export line changes.
--- FAIL: TestValidate (0.00s)
signup_test.go:55: testdata/signups.json: seed 20261026, 12 rows
--- FAIL: TestValidate/row-05-age-18 (0.00s)
signup_test.go:65: Validate({Name:Delilah Blackfield Email:[email protected] Age:18 Country:Sweden}) = signup: under 18, want <nil>
FAIL
Row 5 catches the off-by-one, and the parent test's log line, which
go test prints only on failure or with -v, records which fixture
and seed the run used.
Golden files in Go tests with an -update flag
Table rows work when each case has a small expected value. When the
output is large, such as a rendered report, generated code, or an
export, writing the expected value into a struct is impractical. The
golden file convention stores the expected output in testdata/ and
compares against it. It is a convention, not a feature of the
testing package: you write the comparison and the update switch
yourself.
The function under test writes a CSV review export, one line per signup with its validation error:
// WriteCSV writes a review export: one line per signup, with the
// validation error (if any) in the last column.
func WriteCSV(w io.Writer, signups []Signup) error {
cw := csv.NewWriter(w)
header := []string{"name", "email", "age", "country", "error"}
if err := cw.Write(header); err != nil {
return err
}
for _, s := range signups {
errText := ""
if err := Validate(s); err != nil {
errText = err.Error()
}
rec := []string{
s.Name, s.Email, strconv.Itoa(s.Age), s.Country, errText,
}
if err := cw.Write(rec); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
The golden test feeds it every fixture row and compares the result
with testdata/export.golden. The update flag declared at the top of
the test file switches it into rewrite mode:
func TestWriteCSVGolden(t *testing.T) {
fx := loadFixture(t)
signups := make([]Signup, len(fx.Rows))
for i, row := range fx.Rows {
signups[i] = row.Input
}
var got bytes.Buffer
if err := WriteCSV(&got, signups); err != nil {
t.Fatal(err)
}
golden := filepath.Join("testdata", "export.golden")
if *update {
if err := os.WriteFile(golden, got.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("%v (run go test -update to create it)", err)
}
if !bytes.Equal(got.Bytes(), want) {
t.Errorf("WriteCSV output differs from %s (fixture seed %d)\n"+
"got:\n%s\nwant:\n%s", golden, fx.Seed, got.Bytes(), want)
}
}
Create or refresh the golden file, then review it like any other change:
go test ./signup -run TestWriteCSVGolden -update
git diff signup/testdata/export.golden
For the committed fixture, the first lines of export.golden are:
name,email,age,country,error
,[email protected],36,Austria,signup: missing name
Adella Langham,tristan.example.com,29,Japan,signup: invalid email
Belin Fenson,[email protected],-1,Austria,signup: negative age
Danny Cameron,[email protected],17,Ireland,signup: under 18
Delilah Blackfield,[email protected],18,Sweden,
Three things to know about this pattern:
- Pass
-updateto one package, not./....go testhands flags it doesn't recognise to each test binary, and a package that doesn't define-updateexits withflag provided but not defined: -update. - The golden file is tied to the fixture. Regenerating
signups.jsonchanges the export, so rerun with-updateand commit the fixture, the golden file, and the template together. The diff then shows the data change and its effect side by side. -updatemakes the test pass by definition. It writes the current output and compares against it. The review is thegit diff, so don't run it to make a red test go green without reading the diff.
Reproducing a failure from the logged seed
With a committed fixture, most failures reproduce trivially. CI read
the same signups.json you have locally, so the subtest name from the
CI log reruns the case:
go test ./signup -run 'TestValidate/row-05-age-18' -v
The seed matters when you go looking for data you didn't plan for. A scheduled exploratory job can regenerate the fixture with a seed the API picks and many more rows, then run the table tests against it. It needs the API key, so keep it separate from the checks that gate merges, and leave out the golden test, which is pinned to the committed data and will always differ:
SEED=random ROWS=500 go generate ./signup
go test ./signup -v -run 'TestValidate|TestFixtureCoversEveryError'
The seed appears twice in that job's log: in the script's
wrote ... (seed N, 500 rows) line, and in TestValidate's log line,
which -v always prints. When the job fails, check out the same
commit, so the template matches, and rebuild the exact file:
SEED=2214596771 ROWS=500 go generate ./signup
go test ./signup -run 'TestValidate/row-137'
(The seed and row number are placeholders; use the ones from your own
log.) Same template, same seed, same parameters: the file is
byte-for-byte what the job tested, so the failure reproduces. Once you
understand the bug, turn the failing row into a planted row in the
template, regenerate with the default seed, update the golden file, and
commit. git restore signup/testdata/ puts the committed files back if
you were only investigating.
Reproducible test data from seeds
covers how seeded generation works in more depth.
Go fuzzing vs table-driven tests: when to use which
A generated fixture is extra machinery: a template, a script, an API
key for whoever regenerates, and a JSON file to review. If a function
has three meaningful inputs and nothing else shares them, a
hand-written []struct{...} literal is still the clearer choice.
Use fuzzing to find inputs you didn't think of. Go has had native
fuzzing since 1.18, through testing.F. A fixture pins known cases
with known answers. A fuzz target checks a property against inputs the
fuzzing engine mutates for you. They work well together, because the
fixture rows make a good seed corpus:
package signup
import "testing"
func FuzzValidate(f *testing.F) {
for _, row := range loadFixture(f).Rows {
f.Add(row.Input.Name, row.Input.Email, row.Input.Age)
}
f.Fuzz(func(t *testing.T, name, email string, age int) {
s := Signup{Name: name, Email: email, Age: age}
if Validate(s) == nil && age < 18 {
t.Errorf("Validate accepted %+v", s)
}
})
}
Plain go test runs the target once per seed input, as a normal test.
go test ./signup -run '^$' -fuzz FuzzValidate -fuzztime 30s
actually fuzzes. The -fuzz pattern has to match exactly one fuzz
target in one package. If the fuzzer finds a failing input, it writes
it to testdata/fuzz/FuzzValidate/, and from then on plain go test
replays it as a regression case.
Generate a fixture when the data is shared, larger, or has to be replayed. The file-based approach wins when several packages or services, not all of them in Go, read the same rows; when you want dozens of realistic rows instead of four; and when the edge cases should be visible in a pull request with their expected results next to them. The same pattern carries over to other stacks, as shown for pytest, Jest and Vitest, and Spring Boot.
FAQ
What is the testdata directory in Go?
It is a directory named testdata that the go tool deliberately
ignores when it looks for packages, so it never treats the files inside
as Go source. That makes it the conventional place for fixture files,
golden files, and other inputs a test reads. Because go test runs
each test binary in its package's directory, a test can open
testdata/name.json with a relative path.
How do I load a JSON file in a Go test?
Open it with os.Open(filepath.Join("testdata", "name.json")), decode
it with json.NewDecoder into a struct that matches the file, and call
t.Fatal if either step fails. Put the code in a helper that calls
t.Helper() so failures point at the calling test. Calling
DisallowUnknownFields on the decoder makes the test fail loudly if
the file gains a field your struct doesn't know about.
How do you update golden files in Go tests?
Go has no built-in golden file support, so the update step is a
convention. Define a flag in the test file, for example
var update = flag.Bool("update", false, "rewrite golden files"), and
when it is set, write the actual output to the golden file before
comparing. Run go test with -update for that one package, review
the diff in version control, and commit it.
Do I still need tt := tt in Go table-driven tests?
Not if your go.mod says go 1.22 or later. As of Go 1.22, each
iteration of a for loop gets its own copy of the loop variable, so a
parallel subtest that captures it sees its own row. In modules that
declare an older Go version, the old shared-variable behaviour still
applies, and the tt := tt copy is still needed before calling
t.Parallel.
Should I use fuzzing or table-driven tests in Go?
Use both, for different jobs. A table-driven test pins down known cases
and their expected results, so it documents behaviour and catches
regressions. Fuzzing, available through testing.F since Go 1.18,
searches for inputs you didn't think of and checks a property that must
hold for all of them. The table rows also make a good seed corpus for
the fuzz target.
Does JsonFabrica have a Go SDK?
No. JsonFabrica is a JSON-over-HTTP API and ships no Go SDK or
go generate tool. You call it from your own script with curl, or from
a short Go program using net/http, and save the generated JSON in
your testdata directory. A //go:generate directive can run that
script, but go test itself never calls the API.
A seeded template, a short regen script, and one committed file give
your Go tests a table with planted boundary cases, a golden file to go
with it, and data you can rebuild from a seed. Seeded generation from
a raw template is one request to the JsonFabrica API; the
templates API reference documents its
parameters and the { data, meta } response.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.