Skip to the content.

tuck — Testing Strategy

Companion to cli-spec.md, which is authoritative for behavior; this document is authoritative for how that behavior is tested.

1. Goals

2. Test layers

Layer Scope Speed Tooling
Unit Pure/internal behavior from §12: path primitives, target↔package conversion, classify-target, ownership inference, conflict rules, execution planning. No filesystem, or t.TempDir() only. fast stdlib testing, table-driven
Acceptance The compiled binary against an isolated filetree. Asserts exit code (§10), stdout/stderr (human or JSON, §9), and the resulting filetree (existence, symlink payload, moved files). medium testscript (txtar)

Unit tests own the algorithmic edge cases; acceptance tests own the user-observable contract. A behavior is “done” only when an acceptance test proves it on the real binary.

CLI shell behavior — command parsing, help/version output, diagnostics, stdout, stderr, and process exit status — is user-observable contract and belongs in testscript, not in unit tests for the CLI composition package.

When a command requires positional arguments, prefer urfave/cli’s Arguments configuration over ad hoc action-level missing-argument checks. For example, use a narrow StringArgs argument with Min: 1 and Max: 1 for a required single path, then read it with cmd.StringArgs("<name>") in the action. If urfave’s argument parsing needs a usage hook to match the project’s stream contract, add a small OnUsageError handler that prints Incorrect Usage and command help, then returns an exit-code-only error. Keep truly semantic validation in the action or engine layer.

2.1 Test suites

Tests are grouped into named suites that each run independently, so you can exercise one slice without running the whole tree. A suite is a single Go test function, which keeps go test -run filtering and IDE “run test” gutters working:

mise tasks expose the common groupings: mise run test (unit + command-package + acceptance), mise run test:unit, mise run test:cmd, mise run test:accept (all acceptance, with the tag), and mise run test:accept:<suite> (one suite). Generation/build gates are also taskized: mise run generate, mise run build, mise run vet, and mise run fmt. mise run build depends on generation and vet, so build-time checks analyze regenerated source before compiling. mise run check is the full local/CI gate and depends on test, build, and fmt. mise also pins the Go toolchain version for reproducible local and CI builds.

3. Acceptance harness (testscript)

We use github.com/rogpeppe/go-internal/testscript. It is purpose-built for “compiled binary + isolated $WORK filetree + golden stdout”:

3.1 Build-tag–gated test hooks

The harness needs to redirect two things the spec ties to the host: the root context’s physical target root (/) and the privilege decision. These are exposed only through code compiled under the tuck_testhooks build tag:

//go:build tuck_testhooks

3.2 Custom script commands

testscript builtins cover most needs (exec, exists, ! exists, cmp, stdin, env, mkdir, chmod, cp, mv, rm, symlink). Prefer these native operations over exec-ing shell utilities so scripts stay portable and avoid shell parsing. Use stdin stdout to feed one command’s output into the next instead of a shell pipeline; reserve exec sh -c ... for cases with no direct testscript equivalent. We register a few custom commands:

JSON tests can alternatively assert the envelope’s exitCode field (§9.2), which mirrors the process code, by comparing against a golden document. For larger stable stdout/stderr bodies, prefer cp stdout <file> / cp stderr <file> plus cmp against an inline txtar golden; keep regex fragments for output where ordering or formatting is intentionally non-contractual.

4. The isolated filetree

Each script builds a sandbox under $WORK:

$WORK/
  home/                 # fake $HOME (home-context target root)
  root/                 # fake physical backing root (root-context only; see §5)
  src/                  # a source repo
    .tuck.toml          # committed source manifest; name = "public"
    zsh/.zshrc          # a home-context package
    .root/sshd/etc/...  # a root-context package (base is <source>/.root)
  state/                # machine-local state dir (TUCK_TEST_STATE_DIR)
    tuck/sources.toml   # GENERATED at run time by wanthome/wantroot, not inline
                        #   (its [[source]] path must be the absolute $WORK/src)

Inline txtar bodies are written verbatim; testscript substitutes $WORK only in command lines. So any file that must reference an absolute sandbox path (notably sources.toml) is generated by a setup command, while static files (.tuck.toml, package contents) are carried inline.

Every script creates $HOME itself (the tool must not be relied on to create the target root) and sets a scrubbed environment:

Variable Value Why
HOME $WORK/home home-context target root
TUCK_TEST_STATE_DIR $WORK/state deterministic machine-state discovery (build tag tuck_testhooks)
XDG_STATE_HOME $WORK/state prevent fallback to a real XDG state path
XDG_CONFIG_HOME $WORK/xdg prevent fallback to a real XDG path
NO_COLOR 1 stable colorless output (tests also pass --no-color)
TERM dumb no terminal escape sequences
LANG / LC_ALL C stable sorting and messages
TUCK_TEST_ROOT_DIR unset (home tests) / $WORK/root (root tests) §5
TUCK_TEST_PRIVILEGE unset / granted / denied §5

A fixed umask is set in the harness so any directory-mode assertions are reproducible.

5. Root context: three separate concerns

The root context is the only hard isolation problem, because targetRoot(root) = /. The rubber-duck review showed that conflating “where root writes go” with “is this allowed” is wrong, so we keep three independent concerns separate. Only the first two have test hooks; the third is a normal product policy with an injectable check.

5.1 Logical context (unchanged)

--root still means the root context with package base <source>/.root. All CLI-visible paths stay logical: plan/JSON/human output for a root-context operation prints /etc/ssh/sshd_config, never the physical sandbox path. This guarantees acceptance goldens match production output.

5.2 Physical backing root (test hook: TUCK_TEST_ROOT_DIR)

Under the tuck_testhooks tag, the physical root for filesystem operations is:

So a logical target /etc/ssh/sshd_config is physically created at $TUCK_TEST_ROOT_DIR/etc/ssh/sshd_config. To make logical-vs-physical bugs visible, JSON output includes the effective physical root as a debug field only when the hook is active; production JSON omits it. Root-context tests assert the logical path in output and use readlink on the physical path — never inferring the symlink payload from output alone.

5.3 Privilege authorization (policy, not writability)

Privilege is a preflight policy, decided before any mutation, and is not derived from whether the target root happens to be writable (writability of / is neither necessary nor sufficient — a plan may touch read-only subtrees, and remove_symlink/move depend on parent directories, not the root):

  1. Build the conflict-free plan.
  2. If root context and the plan contains write actions (mkdir/symlink/remove_symlink/move), the plan is marked as requiring privilege.
  3. The privilege check is a single injectable predicate. Production: the process is privileged (e.g. euid == 0, or holds the needed capability). Tests (under the tag): TUCK_TEST_PRIVILEGE=granted|denied forces the answer deterministically — no reliance on chmod 0555 (which root can bypass) or on the test runner’s real euid.
  4. If --apply is given and privilege is not satisfied: print the plan, mutate nothing, and exit 1 with privilege.satisfied = false.
  5. Otherwise apply. A genuine filesystem error during apply is error.code io_error — the privilege failure path must mutate nothing.

This decoupling means redirecting the physical root (§5.2) never grants privilege, and forcing privilege never changes where bytes land.

5.4 Privilege in output

To avoid the contradiction of “privileges required” alongside a successful non-root apply, output distinguishes marker from enforcement:

"privilege": { "required": true, "satisfied": true, "reason": "root-context write" }

This matches §8.1 (privilege as an explicit preflight policy) and the privilege object in §9.2.

6. What every acceptance test asserts

  1. Exit statusexec tuck ... for success and ! exec tuck ... for expected failure. Since process exits are binary (0/1), tests assert detailed failure classification via stderr or the JSON error.code / exitCode fields rather than a custom exit-code command.
  2. Stdout — golden console text (--no-color) or a golden JSON document. Primary results only (plans, listings, status, the JSON envelope).
  3. Stderr — diagnostics (error:/hint: lines, usage text) land on stderr, not stdout (§9). Error scripts assert a stderr golden; success scripts assert stderr is empty. --help and usage text are checked loosely (exit code + a key substring), never pinned verbatim, so a CLI-framework (urfave/cli) upgrade does not churn goldens. CLI shell cases such as --help, --version, unknown commands, and unknown flags are covered here rather than through internal package unit tests.
  4. Filetreeexists / ! exists, and readlink for the exact symlink payload (the spec’s relative form, e.g. ../src/zsh/.zshrc); output is never used to infer the payload.

Determinism checklist

7. Red/green workflow

For each behavior:

  1. Compile seam. If a unit test needs a new production API, first add the smallest production-owned compile seam for that API. The seam may return a typed not-implemented error or another clear failing result, but it must be narrow, intentional, and usable by production code later. Do not add exported test-only APIs just to satisfy tests.
  2. Red. Add the unit test and/or testdata/script/<feature>.txtar describing the desired behavior. The package or acceptance suite must compile, then fail for the expected behavioral reason: unimplemented behavior, wrong output, wrong exit status, missing filesystem effect, or an unmet assertion. A compile error, missing symbol, malformed txtar script, broken helper, stale fixture shape, or harness setup failure is not a valid red state; fix the test before starting the green implementation.
  3. Green. Implement until the unit test or script passes.
  4. Refactor with the test as the safety net.

Prefer minimal production compile seams for unit-level red tests. For example, if the slice needs state.Save or state.AddSource, introduce the narrow function signature in internal/state first with deliberately incomplete behavior, then add unit tests that compile and fail because the behavior is missing. Acceptance tests should also be added early for command slices because they prove the user-observable CLI compiles and fails before command wiring is implemented.

Typed application error sentinels follow the same compile-first rule. Add a small string type, constants, and a one-line Error() method, then call internal/apperr helpers directly: AppErrMsg / AppErrMsgf for context-only errors and AppErrWrap / AppErrWrapf for errors that preserve a cause. Use *apperr.Error[pkg.ErrKind] when tests need typed app-error metadata. Use mise run check for the full gate.

This pairs naturally with plan-by-default: a single script first runs the command without --apply and asserts the filetree is unchanged (the plan is printed, nothing mutates), then runs with --apply and asserts the mutation. The no-mutation guarantee is itself a first-class assertion.

Coverage map

Coverage is organized by suite (§2.1); each suite owns the slice of the contract below. The first group is implemented; the remaining suites are planned as the corresponding product slices land.

Mode assertions should use an acceptance helper or direct testscript stat assertion that is stable under the harness umask. Copied-file tests must assert filesystem bytes, applied mode, and recorded state so that copy ownership is not inferred accidentally from path shape.

8. Example (home package use, red→green)

# package/package.txtar
wanthome                  # creates $WORK/home and generates $WORK/state/tuck/
                          #   sources.toml with the default source -> $WORK/src

# plan only: nothing changes
tuck pkg use zsh --no-color
! exists $WORK/home/.zshrc

# apply: link is created with the expected (relative) payload
exec tuck pkg use zsh --apply --no-color
exists $WORK/home/.zshrc
readlink $WORK/home/.zshrc ../src/zsh/.zshrc

-- src/.tuck.toml --
name = "public"

-- src/zsh/.zshrc --
# zshrc contents

The payload is ../src/zsh/.zshrc, i.e. relativePath(dirname($WORK/home/.zshrc), $WORK/src/zsh/.zshrc), matching the spec’s relative-payload rule (§12.7) — never an absolute path.

9. CI