name: 09-test-writing description: Translates the test spec into compiling hspec tests that all fail against stub implementations. kind: leaf executor: sonnet model: claude-sonnet-4-6
Test Writing
Translates the test spec into Haskell hspec tests and stub implementations such that everything compiles and every test fails.
Inputs
docs/architecture/<adr-number>-<slug>-tests.md— test spec.docs/architecture/<adr-number>-<slug>.md— architecture doc.
Plan
- Read the test spec and architecture doc → verify: both exist.
- Translate every spec case into an
hspectest → verify: case count matches spec. - Create implementation stubs with
Task.throworerrorso the module compiles → verify:nix develop --command cabal build allsucceeds. - Register the test suite in
nhcore.cabaland confirm every test fails → verify:nix develop --command cabal testruns and every new test is red.
Assumptions:
- Tests follow NeoHaskell style: pipes,
do+let,case...of, qualified imports,[fmt|...|]. - Stubs use
Task.throwforTaskreturns anderror "not implemented"for pure ones. Stubs must throw a sentinel value (e.g.error "not implemented"or a dedicatedNotImplementedconstructor) that cannot match the concrete domain error a real test expects — never use the same domain error constructor the spec asserts against. - Error-path tests assert the exact domain error constructor (and any payload fields the architecture doc names), not a generic
shouldThrow/anyException matcher. A test that only catches "any exception" is a false-green hazard against the stub above and must be rewritten before the suite is admitted. - nhcore has
Strictglobally — never add!annotations.
If any assumption fails, refuse — do not guess.
Steps
- Load the test spec and architecture doc.
- Create the source module under the path specified by the architecture doc, with stub function bodies.
- Create the test module under
core/test/...mirroring the source path. - For each spec case, write an
hspecitblock referencing the spec case name. - Register the new test module in
nhcore.cabal(and the source module if new). - Run
nix develop --command cabal build allto confirm compilation. - Run
nix develop --command cabal test --test-show-details=streamingto confirm every new test fails. - Run
python3 .claude/skills/feature-pipeline-preview/scripts/pipeline.py complete 9.
Output
Test module and stub source module written, cabal updated, every new test compiling-but-failing, phase 9 marked complete.
Refusals
- Test spec or architecture doc missing → refuse: "prerequisite phase output missing".
- Build fails after stubbing → refuse and surface the build error.
- Any new test passes against a stub → refuse: "test
passes against stub; spec is wrong". - Any error-path test uses a generic
shouldThrow/anyException-style matcher instead of asserting the exact domain error constructor and payload → refuse: "testwould false-green against Task.throw/errorstubs; assert the concrete error". - Setup-error swallowing. Any
case … of Err (ConnectionFailed _) -> pass(or anyErr _ -> passshape on a fixture/setup call likemkStore,createTestStore,InMemory.new) → refuse: "tests must not absorb infrastructure failures; either make the dependency a hard prerequisite or mark the testpendingwith a rationale that names the missing fixture". - Mismatched name vs body. If the test name contains
emits/reads in chunks/logs/deletes/writes/updates/replays/resumes, the body's assertion must reference the same primitive. Otherwise → refuse: "test name promises a side effect the body never observes; either assert the effect or markpendingwith a rationale". - Trivial-fixture error-path test. For any
it "fails with <X>" \_ -> do …whose setup is the canonicalSubscriber.new <InMemory>.new Registry.emptyor equivalent empty-fixture pattern AND whose body assertsErr (<X> _) -> pass→ refuse: "error-path test needs a fixture that actually triggers; empty registry + InMemory cannot reach the branch". - Panicky-let non-behavioral test. If a test body is just
let _x = builder ...followed bypass→ refuse: "test asserts no behavior; either add a meaningful assertion or remove the test".
Static checks for the four patterns above are encoded in ../../scripts/lint-test-patterns.py. The phase 9 leaf MUST invoke it against every new spec file as a final guard. If the script exits non-zero (any finding), the leaf MUST refuse and NOT call pipeline.py complete 9 — surface the findings to the maintainer instead.