# Test Writer

> Writes tests in mecatl (hexagonal / strict DDD, two Go modules) following the invariant-first discipline. Picks the right layer, the right naming convention, and the right fake pattern (offline reference adapters — mockllm / memfs / memstore — plus the shared conformance suites, never a live network call or a mock-framework mock of a port). Use when adding tests, writing a new package's test surface, porting a failing scenario into a regression test, or pinning an ADR rule / AGENTS.md invariant. NOT for authoring the acceptance plan (use /to-acceptance-plan).

- Skill: `stacklok/test-writer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add stacklok/test-writer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/stacklok/test-writer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: stacklok (https://skillmd.com/u/stacklok)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/stacklok/test-writer

---


# test-writer

## Purpose

Every mecatl test answers four questions in order: what behavior, invariant, ADR, or
acceptance scenario does it defend? what layer does it live at? what naming convention does it
follow? what fake or fixture does it need? This skill walks you through those four questions
and emits a test stub.

## Prerequisites

- Read [`AGENTS.md`](../../../AGENTS.md) — the layering rule and the
  invariants under "Things That Will Bite You" (the domain model's
  equivalent: each bullet there is a defended invariant, most with a named
  pinning test already).
- Read [`docs/architecture.md`](../../../docs/architecture.md) — the layers,
  the ports, where each kind of test lives.

## Workflow

### Step 1: Identify the invariant or ADR being defended

Name the test after the rule it defends. The first two patterns are what
`ac-trace` gates against:

- `TestInvariant_<id>` — an invariant from `AGENTS.md` ("Things That Will
  Bite You") or `docs/design/IMPLEMENTATION-NOTES.md`, id kebab → snake.
  Example: `TestInvariant_deny_dominant_scope_resolution`.
- `TestADR_NNNN_*` — a rule codified in `docs/adr/NNNN-*.md`. Example:
  `TestADR_0041_DirectWriteSubagent`.
- `Test<Plan>_Scenario<N>_*` — a scenario test a `docs/acceptance/<plan>.md`
  scenario claims (the orphan gate fails a scenario test no landed plan
  tracks). Example: `TestSessionProfiles_Scenario2_NoFSRestart`.
- Descriptive unit names (`TestFoo_Bar`) for everything else — fine for
  ordinary coverage, but an AC's `verify:` line should name one of the
  pinned forms above when the AC defends a rule.

Rule: every test answers "if this fails, which behavior, invariant, ADR, or scenario is now
wrong?" Descriptive unit behavior is enough for Routine and Bounded work; do not manufacture
an ADR or repository-wide invariant merely to name a test. If the answer is "nothing
observable", consider whether the test earns its place.

### Step 2: Pick the layer

Pick the **lowest** layer that actually exercises the rule, and the right
**module**: `engine/` is its own Go module — a `go test ./...` from the
repo root does NOT cross the boundary; engine tests are a second
invocation from `engine/`.

- **Engine domain unit** — pure aggregate / value-object / invariant tests.
  No I/O, no adapters beyond the reference ones. `engine/session/`,
  `engine/governance/`, `engine/tool/`, `engine/prompt/` `*_test.go`.
  Most invariants pin here.
- **Engine app unit** — the agent loop, dispatch, supervisor against the
  **reference adapters**: `engine/adapter/mockllm` (scripted LLM),
  `engine/adapter/memfs` (in-memory workspace), `engine/adapter/memstore`,
  `engine/adapter/permpolicy`. `engine/agent/*_test.go`. Nothing under
  `engine/` imports `internal/...` — integration tests that need a heavy
  adapter live next to that adapter under `internal/adapter/`.
- **Port conformance** — one suite per port family, run against **every**
  adapter of that port so a fake can never drift: `engine/adapter/memconformance`,
  `engine/adapter/storeconformance`, `engine/adapter/fsconformance`,
  `engine/adapter/sourceconformance`, `engine/adapter/leaseconformance`.
  A new adapter plugs into the EXISTING suite; it does not hand-roll its own.
- **Adapter integration** — a heavy adapter against its real dependency,
  offline: `internal/adapter/<name>/*_test.go` (SSE→Chunk paths are tested
  from fixtures; miniredis for the Redis store).
- **End-to-end** — the offline demo path (`cmd/mecademo`) or a full
  `app.Build` loop test under `internal/app/`. The LIVE provider e2e
  (`task e2e`, real money) is never part of the gate.

**Tests are offline, always.** Never hit a live model or network — the
depguard + CI enforce it. If a test seems to need the network, the seam is
wrong: script the `mockllm` or record a fixture.

### Step 3: Pick the fake or fixture

mecatl is hexagonal: the engine owns the port interfaces (`engine/port`);
adapters implement them.

- **Mock-framework mock of a port:** banned for ports. A mocked
  `LLMProvider` or `SessionStore` passes tests that fail against the real
  adapter. Use the reference adapters instead — they ARE the fakes, and
  the conformance suites keep them honest.
- **`mockllm` for the provider:** script chunks/tool calls per turn; never
  the network.
- **`memfs` for the workspace:** in-memory FileSystem/Workspace. (For a
  no-fs profile test, `engine/adapter/nofs`.)
- **`memstore`/`memlease` for persistence seams**, exercised through the
  conformance suites.
- **Fixtures:** deterministic — no `time.Now()`-sensitive assertions in
  domain tests (the `port.Clock` seam exists; `engine/adapter/wallclock`
  is production-only), no randomised ids where an id matters.
- **Mutation checks where the repo already has them:** the oracle-style
  tests (e.g. `command_runner_secret_scrub_test.go`) plant the violation
  and assert red. Follow that pattern for negative invariants.

### Step 4: Emit the test stub

Do not copy a constructor from this document: test helpers and `agent.Deps`
change as the engine evolves. Locate the nearest current test that exercises the
same layer and seam, then adapt its fixture and constructor shape. Confirm every
field and helper against the current package before writing the failing test.
Prefer an existing `newTest*` helper or reference-adapter fixture over creating a
new harness.

### Step 4.5: Make sure the test can actually fail

A test that passes for the wrong reason is worse than no test — it
manufactures false confidence. Two cases need an explicit "watch it fail"
step before you trust a green result:

- **Negative / absence assertions** ("X is NOT forwarded", "no child
  content enters the parent log", "the env is scrubbed"). These pass
  trivially when they inspect the wrong field or surface. Plant the
  violation (make the thing happen), confirm the test goes **red**, then
  revert. Where you can, derive the forbidden set from the run's own
  output rather than a hard-coded literal that drifts.
- **Timing / lifecycle assertions** ("the run cancels", "no goroutine
  leak"). A check with an already-expired context and no assertion can
  only fail by panic. Use a real bound plus a would-block guard, and
  assert the result. `goleak` is available in the engine module for leak
  assertions.

Every test carries at least one assertion that can fail on a real
regression. `_ = err` is not verification.

### Step 5: Verify with the Taskfile

```bash
task test          # both modules + the engine-standalone hygiene proof
cd engine && go test ./agent/ -run TestYourNewTest   # a single engine test
```

Then check whether the implementation contradicts its declared work classification or
introduces an unplanned durable decision. Stop as contract drift rather than silently
upgrading/downgrading it. Only Architectural work with a genuinely new or superseding durable
decision adds an ADR and its `TestADR_NNNN_*` pin; a current invariant may instead belong in
AGENTS.md / IMPLEMENTATION-NOTES.md with `TestInvariant_<id>`. Routine and Bounded rationale
stays in the issue, PR, plan, or ordinary test name. If you touched the engine's exported API:
`task api:update` plus the `engine/CHANGELOG.md` note.

## Anti-patterns

- "I'll mock the `LLMProvider` just for this test." Forbidden —
  `mockllm` scripts the stream; the conformance suites pin adapter parity.
- "This behaviour is proven — my fake asserts it." Only if the same
  conformance suite runs against the real adapter. Fake-only is not proven.
- "I'll hit the real provider to check." Never — offline only; the SSE
  adapters are fixture-tested, and `task e2e` is a separate, manual gate.
- "I'll assert that a guide contains these phrases." Do not pin arbitrary prose or keyword lists. Test links/anchors, parsed executable examples, schemas, and generated-output freshness; leave prose semantics and completeness to human review. Model-visible prompt affordance tests remain required because runtime behavior depends on them.
- "I changed a test because the implementation changed." When tests fail,
  fix the implementation, not the tests.
- "My negative test passes." Did you watch it fail when the violation is
  planted? A green absence-assertion you never saw red is hollow — it
  often checks the wrong field while the value rides another.
- "I'll run `go test ./...` from the repo root to check the engine." The
  module boundary swallows it — engine tests run from `engine/`.

## See also

- `AGENTS.md` — the layering rule, the invariants, the Taskfile contract.
- `docs/architecture.md` — the layers and the dependency rule.
- `docs/adr/` — the frozen decisions your tests may pin.
- `docs/acceptance/README.md` — the `verify:` contract ac-trace gates.

