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/goa4web templates (core/templates)

The core theme is that there is a range of sophistication:

  1. Simple input/output data pairs
  2. Case metadata + descriptions
  3. 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.txt or 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 write output/... in-memory
  • compare output/... against expected/... 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:

  1. Each .txtar has a short case identifier and intent.
  2. Required file names are documented (input.*, expected.*, optional options.json, optional error.txt).
  3. Harness supports both success and expected-failure scenarios.
  4. Fixture discovery is embed-based and path-stable.
  5. Tests are t.Run subtests 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:embed preferred)

If there’s a trade-off, choose explicit structure over clever compactness. That pays off when the fixture corpus gets large.