Txtar Test Systems in Practice: Iterating to Scale (v2)
I use txtar as a practical test and fixture format across multiple repositories.
This post is my updated reference for how I expect agents (and future me) to structure
and evolve txtar-based systems.
Repositories discussed:
arran4/golang-rcs(testdata/txtar)arran4/golang-diff(pkg/diff/testdata)arran4/editorconfig-guesser(testdata)- Related pattern:
arran4/goa4webtemplates (core/templates)
The core theme is that there is a range of sophistication:
- Simple input/output data pairs
- Case metadata + descriptions
- Full scenario definitions with options and assertions
Since then, the approach has evolved, specifically when dealing with larger test suites and complex context mocking (like validating fstab entries against a mock /proc/filesystems). This post covers the iteration of these patterns to scale better, as demonstrated by recent pull requests.
Why txtar
txtar is great when you want fixtures that are:
- Human-readable in reviews
- Easy to compose from multiple files
- Stable enough for golden-style assertions
- Friendly to tooling and directory walking
In practice, this means tests can start tiny and grow naturally without changing fixture format.
For advanced use cases requiring programmatic modification of archives or a writable fs.FS interface, you can use the fork github.com/arran4/txtar. However, it should only be used when it provides a clear benefit over the standard golang.org/x/tools/txtar tooling and library.
Pattern 1: simple pairs (minimum viable structure)
At the low end, a txtar can just model pairs:
- one file for input
- one file for expected output
Example shape:
1-- input.txt --
2line 1
3line 2
4-- expected.txt --
5line 1
6line 2 (normalized)
This is ideal for parser normalization, text transforms, or diff behaviour where you only need deterministic before/after checks.
When to choose this
- Algorithm is pure and deterministic
- No runtime options are required
- You want quick fixture authoring speed
Pattern 2: descriptive cases (metadata + intent)
The middle pattern keeps txtar file payloads but adds semantic context:
description.txtor top-level comments for intent- extra files for knobs (
options.json,flags.txt, etc.) - explicit failure/success expectation
Example:
1test: trim trailing spaces in mixed indentation
2Ensures nearest config wins over parent defaults.
3
4-- input.txt --
5a
6\tb\t
7-- options.json --
8{"trim_trailing": true, "normalize_tabs": false}
9-- expected.txt --
10a
11\tb
This is useful for repositories where fixture meaning matters as much as fixture content, especially when agents generate or maintain these cases.
Pattern 3: full scenario tests (options + assertions matrix)
At the high end, txtar becomes a scenario container:
- multi-file source tree inside one archive
- per-case options
- expected outputs and/or expected errors
- optional snapshots of diagnostics
Example:
1test: recursive editorconfig inference with override
2Ensures nearest config wins over parent defaults.
3-- test-options.json --
4{"run_checks": ["indentation"]}
5
6-- input.json --
7{"data": true}
8
9-- fs/src/main.go --
10package main
11-- fs/.editorconfig --
12root = true
13[*]
14indent_style = space
15-- fs/sub/.editorconfig --
16[*.go]
17indent_style = tab
18-- request.json --
19{"path":"fs/src/main.go"}
20-- expected.json --
21{"indent_style":"tab"}
This style maps well to systems like editorconfig-guesser, where behaviour is
contextual and directory-sensitive.
The Problem: The Monolithic Txtar
Initially, it’s tempting to throw all cases into a single fs.txtar file:
1-- proc_filesystems --
2nodev sysfs
3 ext4
4-- valid_fstab --
5/dev/sda1 / ext4 defaults 0 1
6-- invalid_fstab --
7/dev/sda2 / zfs defaults 0 1
This works for a while, but as the number of rules and edge cases grows, this monolithic file becomes unreadable. A failure in one scenario requires hunting through a massive file.
Iteration 1: Granular Files and fs.WalkDir
The most significant structural improvement is moving from a single testdata/fs.txtar to a directory of scenario-specific archives (testdata/*.txtar).
1testdata/
2 fs_valid.txtar
3 fs_invalid.txtar
4 swap_valid.txtar
5 swap_invalid.txtar
Instead of hardcoding a single file read, the test harness walks the directory, creating a distinct t.Run subtest for each .txtar file:
1//go:embed testdata/*.txtar
2var testdataFS embed.FS
3
4func TestRules(t *testing.T) {
5 var cases []string
6 err := fs.WalkDir(testdataFS, "testdata", func(p string, d fs.DirEntry, err error) error {
7 if err != nil { return err }
8 if d.IsDir() || !strings.HasSuffix(p, ".txtar") { return nil }
9 cases = append(cases, p)
10 return nil
11 })
12 if err != nil {
13 t.Fatalf("failed to walk testdata: %v", err)
14 }
15 sort.Strings(cases)
16
17 for _, tc := range cases {
18 raw, err := testdataFS.ReadFile(tc)
19 if err != nil {
20 t.Fatalf("failed to read testcase %s: %v", tc, err)
21 }
22 ar := txtar.Parse(raw)
23 inputFS, expectedFS := SplitInputExpected(ar)
24 // ... run assertions ...
25 }
26}
This isolates failures to specific case files and makes it trivial for agents or humans to add new test cases without touching existing ones.
Important implementation detail: shadow loop variables (tc := tc) if running inside t.Run concurrently so closures don’t capture the wrong value.
Iteration 2: Sane Defaults and Fallbacks
In systems that rely on external context (like reading system configuration files), requiring every .txtar file to redefine the exact same mock data creates massive boilerplate.
If 90% of our tests use the same /proc/filesystems mock, we shouldn’t force the fixture to include it. Instead, the test harness should supply a fallback if the fixture omits it:
1// Pre-fill cache (mocking the OS interaction)
2c := rules.NewCache()
3procData, err := fs.ReadFile(inputFS, "proc/filesystems")
4if err != nil {
5 // Fallback to a sane default if the test doesn't override it
6 procData = []byte("nodev\tsysfs\nnodev\ttmpfs\nnodev\tproc\n\text3\n\text4\n\tsquashfs\n\tvfat\n")
7}
Now, a standard valid case can be incredibly concise:
1test: valid swap partition entry
2Ensures that correctly formatted swap fstab entries pass validation.
3
4-- input/etc/fstab --
5UUID=ccd57c54-3caf-47ba none swap defaults 0 0
Iteration 3: Descriptive Assertions and Implicit Success
When adopting the expected/ tree structure, avoid generic names like expected/output.txt. If your tool outputs validation errors, name the expectation expected/validationerrors.txt. This makes the intent of the golden file immediately clear without opening it.
Furthermore, we can leverage implicit success assertions. If expected/validationerrors.txt is missing from the .txtar archive, the test harness should assume zero validation errors were expected:
1wantOutputBytes, err := fs.ReadFile(expectedFS, "validationerrors.txt")
2if err != nil {
3 if errors.Is(err, fs.ErrNotExist) {
4 // No expected output provided, meaning it should be valid
5 if len(allSuggestions) > 0 {
6 t.Fatalf("expected valid entry to have no suggestions, got %v for file %s", allSuggestions, tc)
7 }
8 } else {
9 t.Fatalf("unexpected error reading expected output: %v", err)
10 }
11} else {
12 // Expected output provided, assert against it
13 // ...
14}
This drastically reduces the footprint of “happy path” tests, keeping fixtures focused strictly on the unique behavior they are verifying.
Deprojected snippets: txtar to fs.FS and deterministic walking
This is the bit I want agents to internalise: parse once, convert to an in-memory
fs.FS, then run your product code against that virtual tree.
Minimal conversion: txtar archive to fstest.MapFS
1package fixturefs
2
3import (
4 "path"
5 "strings"
6 "testing/fstest"
7
8 "golang.org/x/tools/txtar"
9)
10
11func ArchiveToMapFS(ar *txtar.Archive) fstest.MapFS {
12 out := fstest.MapFS{}
13 for _, f := range ar.Files {
14 name := path.Clean(strings.TrimPrefix(f.Name, "/"))
15 if name == "." {
16 continue
17 }
18 out[name] = &fstest.MapFile{Data: append([]byte(nil), f.Data...)}
19 }
20 return out
21}
This lets you remove runtime disk I/O from the test itself while still exercising
code that accepts an fs.FS.
Convention helper: split one txtar into input/expected filesystems
If your archive stores source files under input/ and expected files under
expected/, split them into two independent trees:
1func SplitInputExpected(ar *txtar.Archive) (input, expected fstest.MapFS) {
2 input = fstest.MapFS{}
3 expected = fstest.MapFS{}
4
5 for _, f := range ar.Files {
6 switch {
7 case strings.HasPrefix(f.Name, "input/"):
8 input[strings.TrimPrefix(f.Name, "input/")] = &fstest.MapFile{Data: f.Data}
9 case strings.HasPrefix(f.Name, "expected/"):
10 expected[strings.TrimPrefix(f.Name, "expected/")] = &fstest.MapFile{Data: f.Data}
11 }
12 }
13 return input, expected
14}
That pattern is deliberately boring and explicit: easy for humans to read, easy for agents to generate, easy to validate.
Deterministic walker over virtual FS
Even with an in-memory filesystem, keep ordering explicit:
1func WalkFiles(root fs.FS, dir string) ([]string, error) {
2 var files []string
3 err := fs.WalkDir(root, dir, func(p string, d fs.DirEntry, err error) error {
4 if err != nil {
5 return err
6 }
7 if d.IsDir() {
8 return nil
9 }
10 files = append(files, p)
11 return nil
12 })
13 sort.Strings(files)
14 return files, err
15}
That gives stable case execution and stable diffs.
Multi-template directory loop (goa4web-style pattern)
For template corpora (for example email templates where each body type has its own txtar), I want one subtest per template archive discovered by walking a directory.
1//go:embed testdata/templates/**/*.txtar
2var templateCases embed.FS
3
4func TestTemplateMatrix(t *testing.T) {
5 var cases []string
6 err := fs.WalkDir(templateCases, "testdata/templates", func(p string, d fs.DirEntry, err error) error {
7 if err != nil {
8 return err
9 }
10 if d.IsDir() || !strings.HasSuffix(p, ".txtar") {
11 return nil
12 }
13 cases = append(cases, p)
14 return nil
15 })
16 if err != nil {
17 t.Fatalf("walk template cases: %v", err)
18 }
19 sort.Strings(cases)
20
21 for _, tc := range cases {
22 tc := tc
23 t.Run(strings.TrimSuffix(path.Base(tc), ".txtar"), func(t *testing.T) {
24 raw, err := templateCases.ReadFile(tc)
25 if err != nil {
26 t.Fatalf("read %s: %v", tc, err)
27 }
28 ar := txtar.Parse(raw)
29 inputFS, expectedFS := SplitInputExpected(ar)
30
31 gotFS, err := renderTemplates(inputFS)
32 if err != nil {
33 t.Fatalf("render %s: %v", tc, err)
34 }
35 assertTreeEqual(t, expectedFS, gotFS)
36 })
37 }
38}
This pattern scales from a handful of templates to hundreds while still making failures obvious and localised.
Why this helps agents and embedded script runners
Packing all case inputs/expectations into txtar + in-memory fs.FS gives two
big practical wins:
- Single source of case truth: readers only inspect one fixture blob.
- Host-independent execution: fewer path/permission surprises in CI, local, and agent sandboxes.
The same structure also ports well to Go-based embedded scripting engines:
- pass a virtual filesystem adapter into the script runtime
- let scripts read
input/...and writeoutput/...in-memory - compare
output/...againstexpected/...without touching host disk
So tests, generators, and scripted transforms can all share one fixture model.
Bridging to template systems (goa4web/core/templates)
While goa4web/core/templates is template-driven rather than testdata-driven,
it shares the same operating pattern:
- walk directories
- load file bundles
- transform/render
- compare against expectations or desired outputs
The same harness discipline applies:
- stable directory traversal
- deterministic file naming
- explicit options per case
- clear failure messages tied to relative paths
So even outside strict tests, txtar thinking is still useful: package related inputs as a single scenario unit, then run predictable transformations.
Suggested canonical fixture contract for agents
When asking agents to add or update fixtures, I want this contract:
- Each
.txtarhas a short case identifier and intent. - Required file names are documented (
input.*,expected.*, optionaloptions.json, optionalerror.txt). - Harness supports both success and expected-failure scenarios.
- Fixture discovery is embed-based and path-stable.
- Tests are
t.Runsubtests by relative fixture path.
This keeps changes scalable from simple pairs to full scenarios without changing project fundamentals.
Practical evolution strategy
A pattern that has worked well for me:
- Start new behaviour with Pattern 1 fixture pairs.
- If meaning becomes ambiguous, introduce Pattern 2 description/options files.
- If behaviour becomes contextual or tree-based, move to Pattern 3 scenarios.
- Keep old fixtures valid whenever possible to avoid churn.
That path gives fast feedback early and strong coverage later.
Copy-paste starter layout
1testdata/
2 txtar/
3 normalize-whitespace.txtar
4 parser-error-missing-header.txtar
5 nested-resolution-basic.txtar
And inside a richer case:
1test: nested resolution basic
2
3-- description.txt --
4Ensures nearest config wins over parent defaults.
5-- options.json --
6{"strict":true}
7-- fs/project/.editorconfig --
8root = true
9[*]
10indent_style = space
11-- fs/project/pkg/.editorconfig --
12[*.go]
13indent_style = tab
14-- fs/project/pkg/main.go --
15package main
16-- expected.json --
17{"indent_style":"tab"}
Final guidance for agent-authored changes
When I ask for txtar updates, optimize for:
- readability first
- deterministic harness behaviour
- easy case-level debugging with
t.Run - no runtime path fragility (
go:embedpreferred)
If there’s a trade-off, choose explicit structure over clever compactness. That pays off when the fixture corpus gets large.