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 — 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 — 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
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.
1---2name: test-writer3description: 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).4---56# test-writer78## Purpose910Every mecatl test answers four questions in order: what behavior, invariant, ADR, or11acceptance scenario does it defend? what layer does it live at? what naming convention does it12follow? what fake or fixture does it need? This skill walks you through those four questions13and emits a test stub.1415## Prerequisites1617- Read [`AGENTS.md`](../../../AGENTS.md) — the layering rule and the18 invariants under "Things That Will Bite You" (the domain model's19 equivalent: each bullet there is a defended invariant, most with a named20 pinning test already).21- Read [`docs/architecture.md`](../../../docs/architecture.md) — the layers,22 the ports, where each kind of test lives.2324## Workflow2526### Step 1: Identify the invariant or ADR being defended2728Name the test after the rule it defends. The first two patterns are what29`ac-trace` gates against:3031- `TestInvariant_<id>` — an invariant from `AGENTS.md` ("Things That Will32 Bite You") or `docs/design/IMPLEMENTATION-NOTES.md`, id kebab → snake.33 Example: `TestInvariant_deny_dominant_scope_resolution`.34- `TestADR_NNNN_*` — a rule codified in `docs/adr/NNNN-*.md`. Example:35 `TestADR_0041_DirectWriteSubagent`.36- `Test<Plan>_Scenario<N>_*` — a scenario test a `docs/acceptance/<plan>.md`37 scenario claims (the orphan gate fails a scenario test no landed plan38 tracks). Example: `TestSessionProfiles_Scenario2_NoFSRestart`.39- Descriptive unit names (`TestFoo_Bar`) for everything else — fine for40 ordinary coverage, but an AC's `verify:` line should name one of the41 pinned forms above when the AC defends a rule.4243Rule: every test answers "if this fails, which behavior, invariant, ADR, or scenario is now44wrong?" Descriptive unit behavior is enough for Routine and Bounded work; do not manufacture45an ADR or repository-wide invariant merely to name a test. If the answer is "nothing46observable", consider whether the test earns its place.4748### Step 2: Pick the layer4950Pick the **lowest** layer that actually exercises the rule, and the right51**module**: `engine/` is its own Go module — a `go test ./...` from the52repo root does NOT cross the boundary; engine tests are a second53invocation from `engine/`.5455- **Engine domain unit** — pure aggregate / value-object / invariant tests.56 No I/O, no adapters beyond the reference ones. `engine/session/`,57 `engine/governance/`, `engine/tool/`, `engine/prompt/` `*_test.go`.58 Most invariants pin here.59- **Engine app unit** — the agent loop, dispatch, supervisor against the60 **reference adapters**: `engine/adapter/mockllm` (scripted LLM),61 `engine/adapter/memfs` (in-memory workspace), `engine/adapter/memstore`,62 `engine/adapter/permpolicy`. `engine/agent/*_test.go`. Nothing under63 `engine/` imports `internal/...` — integration tests that need a heavy64 adapter live next to that adapter under `internal/adapter/`.65- **Port conformance** — one suite per port family, run against **every**66 adapter of that port so a fake can never drift: `engine/adapter/memconformance`,67 `engine/adapter/storeconformance`, `engine/adapter/fsconformance`,68 `engine/adapter/sourceconformance`, `engine/adapter/leaseconformance`.69 A new adapter plugs into the EXISTING suite; it does not hand-roll its own.70- **Adapter integration** — a heavy adapter against its real dependency,71 offline: `internal/adapter/<name>/*_test.go` (SSE→Chunk paths are tested72 from fixtures; miniredis for the Redis store).73- **End-to-end** — the offline demo path (`cmd/mecademo`) or a full74 `app.Build` loop test under `internal/app/`. The LIVE provider e2e75 (`task e2e`, real money) is never part of the gate.7677**Tests are offline, always.** Never hit a live model or network — the78depguard + CI enforce it. If a test seems to need the network, the seam is79wrong: script the `mockllm` or record a fixture.8081### Step 3: Pick the fake or fixture8283mecatl is hexagonal: the engine owns the port interfaces (`engine/port`);84adapters implement them.8586- **Mock-framework mock of a port:** banned for ports. A mocked87 `LLMProvider` or `SessionStore` passes tests that fail against the real88 adapter. Use the reference adapters instead — they ARE the fakes, and89 the conformance suites keep them honest.90- **`mockllm` for the provider:** script chunks/tool calls per turn; never91 the network.92- **`memfs` for the workspace:** in-memory FileSystem/Workspace. (For a93 no-fs profile test, `engine/adapter/nofs`.)94- **`memstore`/`memlease` for persistence seams**, exercised through the95 conformance suites.96- **Fixtures:** deterministic — no `time.Now()`-sensitive assertions in97 domain tests (the `port.Clock` seam exists; `engine/adapter/wallclock`98 is production-only), no randomised ids where an id matters.99- **Mutation checks where the repo already has them:** the oracle-style100 tests (e.g. `command_runner_secret_scrub_test.go`) plant the violation101 and assert red. Follow that pattern for negative invariants.102103### Step 4: Emit the test stub104105Do not copy a constructor from this document: test helpers and `agent.Deps`106change as the engine evolves. Locate the nearest current test that exercises the107same layer and seam, then adapt its fixture and constructor shape. Confirm every108field and helper against the current package before writing the failing test.109Prefer an existing `newTest*` helper or reference-adapter fixture over creating a110new harness.111112### Step 4.5: Make sure the test can actually fail113114A test that passes for the wrong reason is worse than no test — it115manufactures false confidence. Two cases need an explicit "watch it fail"116step before you trust a green result:117118- **Negative / absence assertions** ("X is NOT forwarded", "no child119 content enters the parent log", "the env is scrubbed"). These pass120 trivially when they inspect the wrong field or surface. Plant the121 violation (make the thing happen), confirm the test goes **red**, then122 revert. Where you can, derive the forbidden set from the run's own123 output rather than a hard-coded literal that drifts.124- **Timing / lifecycle assertions** ("the run cancels", "no goroutine125 leak"). A check with an already-expired context and no assertion can126 only fail by panic. Use a real bound plus a would-block guard, and127 assert the result. `goleak` is available in the engine module for leak128 assertions.129130Every test carries at least one assertion that can fail on a real131regression. `_ = err` is not verification.132133### Step 5: Verify with the Taskfile134135```bash136task test # both modules + the engine-standalone hygiene proof137cd engine && go test ./agent/ -run TestYourNewTest # a single engine test138```139140Then check whether the implementation contradicts its declared work classification or141introduces an unplanned durable decision. Stop as contract drift rather than silently142upgrading/downgrading it. Only Architectural work with a genuinely new or superseding durable143decision adds an ADR and its `TestADR_NNNN_*` pin; a current invariant may instead belong in144AGENTS.md / IMPLEMENTATION-NOTES.md with `TestInvariant_<id>`. Routine and Bounded rationale145stays in the issue, PR, plan, or ordinary test name. If you touched the engine's exported API:146`task api:update` plus the `engine/CHANGELOG.md` note.147148## Anti-patterns149150- "I'll mock the `LLMProvider` just for this test." Forbidden —151 `mockllm` scripts the stream; the conformance suites pin adapter parity.152- "This behaviour is proven — my fake asserts it." Only if the same153 conformance suite runs against the real adapter. Fake-only is not proven.154- "I'll hit the real provider to check." Never — offline only; the SSE155 adapters are fixture-tested, and `task e2e` is a separate, manual gate.156- "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.157- "I changed a test because the implementation changed." When tests fail,158 fix the implementation, not the tests.159- "My negative test passes." Did you watch it fail when the violation is160 planted? A green absence-assertion you never saw red is hollow — it161 often checks the wrong field while the value rides another.162- "I'll run `go test ./...` from the repo root to check the engine." The163 module boundary swallows it — engine tests run from `engine/`.164165## See also166167- `AGENTS.md` — the layering rule, the invariants, the Taskfile contract.168- `docs/architecture.md` — the layers and the dependency rule.169- `docs/adr/` — the frozen decisions your tests may pin.170- `docs/acceptance/README.md` — the `verify:` contract ac-trace gates.