This repository has been archived on 2026-07-29. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kitchen-md/.claude/adr/0009-testing-tiers-and-boundaries.md
alexion ab309d3226 feat: add kitchen view command and parser skeleton (task 0003)
Cut the first vertical slice through both layers: a runnable
`kitchen view <file>` that reads a Recipe File, parses it, and renders
it styled to the terminal.

@kitchen-md/core exposes a pure, total `parse(input): DocumentAST` built
on a minimal remark pipeline (parse + frontmatter) with a translation
layer to core's own AST types — frontmatter passthrough, HeadingBlock,
ParagraphBlock, and TextNode. remark types never leak into the public
API.

@kitchen-md/bin's `view` subcommand (commander) reads the file and hands
the DocumentAST to a pure `render(ast): string` (chalk, ANSI
auto-suppressed off a TTY). Fallible file I/O is modelled as a neverthrow
Result over a tagged-union CliError, matched at the boundary: stdout on
success, stderr and exit 1 on failure. The command functions sit beside
the import.meta.main-guarded CLI entry so they are testable in-process.

Tests follow ADR 0009's tiers — unit (parse, render), integration (the
view command's Result), and e2e (the binary as a subprocess) — with
coverage, a path-scoped test-report generator, and a prose fixture
rounding out the tooling. ADR 0008 records errors-as-values at the CLI
boundary; ADR 0009 records the testing tiers.
2026-07-28 23:31:03 -04:00

2.8 KiB

ADR 0009 — Testing Tiers and Boundaries

Status: Accepted

Context

The test suite accumulated overlapping files — unit, integration, smoke, and subprocess — with no crisp definition of what each was responsible for. The result was duplication and confusion: the same behaviour asserted in more than one tier, and no rule for where a given test belonged. Both specs already referred to unit, integration, and smoke tests, but none of them pinned the boundaries.

Decision

Recognise three test tiers, each defined by the seam it exercises.

Unit — one component in isolation, a pure function, asserted by its return value. parse in core and render in bin are the unit seams.

Integration — several components composed in-process, asserted by the value the composed function returns. The view command function — file read, then parse, then render, returning a Result — is the integration seam.

E2E — the binary as a black box. It is spawned as a subprocess and asserted on its exit code and its stdout and stderr, with no knowledge of the internal structure.

The line between what is unit- or integration-testable and what is e2e-only is whether a function returns a value or performs a process-level side effect. A function that returns a value can be asserted in-process. A function that calls process.exit or process.stdout.write can only be observed by spawning the binary. So all logic is pushed into value-returning functions, and the entry shell is kept as thin as possible, because it is the one part reachable only through a subprocess.

Core exposes a single public seam, parse, so it has unit tests only. A whole-fixture parse test is still a unit test on that same seam with a broad input — a corpus test — not a separate tier.

Rationale

Precise, non-overlapping definitions prevent the duplication that a vague unit-integration-smoke split produced. Classifying by seam matches what is actually cheap or expensive to test. Value-returning code runs fast and is visible to coverage in-process, while process-side-effecting code needs a subprocess and is invisible to coverage. Concentrating the process-boundary surface in one thin shell keeps the amount of e2e-only code to a minimum.

Consequences

Each behaviour is tested at exactly one tier: logic at unit or integration in-process, the process boundary at e2e. The e2e tier is deliberately minimal — it verifies wiring such as exit codes and stream routing, not content already proven in-process. Tests are co-located as {module}_test.ts, so the command-function tests live beside the entry module and are integration tests despite the file name. When a command function shares a file with the top-level program.parse(), that call is guarded with import.meta.main, so importing the module for a test does not run the CLI.