Readable Test Fixtures: Prefer Named Data Over Opaque Bytes
A test fixture is part of the test’s explanation. If a reviewer cannot tell what a fixture represents, the test has lost an important part of its documentation.
The clearest example is binary data written directly into a Go string:
1w.Write([]byte("PK\\x03\\x04\\x14\\x00\\b\\x00..."))
It may be technically correct, but it is effectively opaque. A reviewer cannot reasonably inspect the archive, update it safely, or tell whether a one-byte change is intentional.
My default is now: do not inline substantial binary protocol or archive data in a test body. Give it a name and a representation that matches what the test cares about.
If exact bytes matter, use a named embedded fixture
When the precise encoded bytes are the contract, put the fixture in testdata and embed it:
1//go:embed testdata/module-v1.0.0.zip
2var moduleV100Zip []byte
The test becomes descriptive:
1proxy := newProxy(t, map[string]response{
2 "/example/module/@v/v1.0.0.zip": {
3 ContentType: "application/zip",
4 Body: moduleV100Zip,
5 },
6})
The binary file can be inspected with ordinary archive tooling, replaced deliberately, and reused by more than one case.
go:embed is especially useful here because the test no longer depends on its working directory and does not need to copy the fixture to disk merely to read it.
If archive structure matters, generate the bytes from readable input
Often the exact ZIP encoding does not matter. The test only needs a valid archive containing particular files.
In that case, a builder is better than committing a binary:
1zipData := mustZip(t, map[string][]byte{
2 "example/module@v1.0.0/go.mod": []byte(`module example/module
3go 1.22
4`),
5 "example/module@v1.0.0/module.go": []byte("package module\n"),
6})
This is readable in code review and keeps the archive in memory.
For larger trees, a txtar fixture can make the source even clearer:
1-- example/module@v1.0.0/go.mod --
2module example/module
3go 1.22
4-- example/module@v1.0.0/module.go --
5package module
The test helper can turn that readable tree into whatever archive format the protocol needs.
Choose representation according to the assertion
A useful rule is:
| What the test cares about | Fixture representation |
|---|---|
| exact binary bytes | named embedded binary fixture |
| files contained in an archive | readable map or txtar, then build in memory |
| path/tree discovery | fstest.MapFS |
| one small text request/response | inline string or byte slice |
| many protocol responses | declarative response table plus embedded/text fixtures |
The fixture should make the important property obvious and hide incidental encoding detail.
Large HTTP switches are another form of opaque fixture setup
This works for a tiny test server:
1switch r.URL.Path {
2case "/v1/info":
3 w.Write(info)
4case "/v1/mod":
5 w.Write(mod)
6case "/v1/archive":
7 w.Write(archive)
8}
It becomes difficult to read when a test adds many modules, versions, status cases, headers, and alternate responses.
Prefer a small declarative server helper:
1type response struct {
2 Status int
3 ContentType string
4 Body []byte
5}
6
7func newFixtureServer(t testing.TB, routes map[string]response) *httptest.Server {
8 t.Helper()
9 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10 route, ok := routes[r.URL.Path]
11 if !ok {
12 http.NotFound(w, r)
13 return
14 }
15 if route.ContentType != "" {
16 w.Header().Set("Content-Type", route.ContentType)
17 }
18 status := route.Status
19 if status == 0 {
20 status = http.StatusOK
21 }
22 w.WriteHeader(status)
23 _, _ = w.Write(route.Body)
24 }))
25}
Then the test describes the protocol fixture rather than implementing a miniature server inline:
1server := newFixtureServer(t, map[string]response{
2 "/example/module/@v/v1.0.0.info": {Body: infoData},
3 "/example/module/@v/v1.0.0.mod": {Body: modData},
4 "/example/module/@v/v1.0.0.zip": {ContentType: "application/zip", Body: zipData},
5})
If request validation matters, make that part explicit too: record requests, declare expected methods or headers, and compare the recorded requests after the operation.
Embed a fixture tree when the protocol is mostly static data
A larger fake service can use a whole embedded directory:
1//go:embed testdata/proxy
2var proxyFixtureFS embed.FS
A helper can map request paths to files below that root. This keeps protocol data out of Go control flow while still allowing tests to override individual responses for failures.
The same principle applies to JSON APIs, HTML fixtures, package registries, mail messages, and generated source corpora.
Do not make binary fixtures mysterious
An embedded binary fixture should have provenance.
Prefer at least one of:
- a nearby readable source fixture from which it can be regenerated;
- a small generator helper or command;
- a comment explaining what property makes the binary special;
- a deterministic generation test if exact bytes are expected to remain stable.
Otherwise an opaque file merely moves the mystery from a Go literal into testdata.
Standard practice
For test fixtures:
- never inline large escaped binary blobs in a test body;
- use
go:embedfor named exact-byte fixtures when encoded bytes are important; - generate archives in memory from readable maps or txtar when archive contents are what matter;
- prefer declarative HTTP response tables over large path switches;
- keep static protocol data in fixture files rather than control flow;
- make fixture provenance or regeneration obvious when binary bytes are checked in.
A good fixture lets the reviewer understand the scenario before running the test.