test(start-api-todo): make the integration suite bootstrap-only#20
Conversation
Rework the todos integration test so the suite only boots the application
and drives it as a plain HTTP client — no start/demesne detail leaks into
the test bodies.
- Add `src/testkit/boot.ts`: the single seam onto start + demesne. It boots
the real app (`runHost(AppLayer)`, as `server.ts` does) on an ephemeral
port and returns a transport-neutral `{ baseUrl, close }`. A promise bridge
holds the scope open across the suite (kernel invariant K2: the scope's
lifetime IS `use`, with no open/hold/close handle), so `use` never asserts.
- Rewrite `app.integration.ts` as transport-only: one `it` per action, each
asserting the whole response (status and body together). It never mentions
`runHost`, demesne, or a context, so it survives swapping the framework.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Refactors the start-api-todo integration tests to boot the real app once and interact with it purely via HTTP, pushing all runHost/scope-lifetime mechanics into a dedicated testkit bootstrap helper.
Changes:
- Added
src/testkit/boot.tsto bootrunHost(AppLayer)on an ephemeral port and expose{ baseUrl, close }while holding the scope open across the suite. - Rewrote
src/app.integration.tsto be transport-only: one HTTP request per test with combined status+body assertions, plus an explicit teardown/disposability check.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| examples/start-api-todo/src/testkit/boot.ts | Introduces the bootstrap-only seam that runs the real host and keeps the scope open until close() is called. |
| examples/start-api-todo/src/app.integration.ts | Converts integration tests to plain-HTTP client style and adds a teardown/disposability verification. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…error causes - Inject the booted app through a vitest `test.extend` fixture (per test), so the in-memory repo starts empty every test: each test is autonomous and order-independent. Drops the shared `createdId` seed. - Set env via `vi.stubEnv` + `unstubEnvs: true` (config) instead of mutating `process.env`, so PORT/LOG_LEVEL are restored before every test and never leak across tests or files. - Preserve the underlying error as `cause` on startup/shutdown failures, so a boot or teardown failure keeps ConfigError.issues / ListenError.cause. - Assert whole responses through a `responseOf` snapshot helper (a Response can't be compared with `toEqual` directly — one-shot body stream). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed in
Copilot
One note: the fixture's first arg is
|
Wrap the fixture's `use(app)` in try/finally so `app.close()` runs even when a test throws (assertion failure or timeout); otherwise the listener would leak into later tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Fixed in |
What
Rework the todos integration test so the suite only bootstraps the application and drives it as a plain HTTP client. No start/demesne detail leaks into the test bodies — if start + demesne were swapped out tomorrow,
app.integration.tswould stay identical.Why
The old test ran every assertion inside
runHost'susecallback, split each check into separate status/body assertions, and packed all CRUD actions into a singleit. That coupled the test to the concrete framework and made failures hard to localize.The root cause:
runHost(and theLayer.scopedit builds on) deliberately has no open/hold/close handle — the scope's lifetime is theusecallback (kernel invariant K2). So there was nowhere else to stand while the server was alive.How
src/testkit/boot.ts— the single seam onto start + demesne. It boots the real app (runHost(AppLayer), exactly asserver.tsdoes) on an ephemeral port and returns a transport-neutral{ baseUrl, close }. A two-promise bridge holds the scope open across the suite:usepublishes the base URL outward and parks on areleasepromise;close()fires it so every finalizer runs in reverse.usenever asserts. A boot failure is raced against readiness so it throws inbeforeAllwith the real cause instead of hanging.app.integration.tsas transport-only: oneitper action (create / list / get / 404 / 400), each asserting the whole response (status and body together). A separateteardowntest boots its own app, closes it, and proves the port stops accepting (factor IX: disposability).runHostitself is untouched — a returned live context outlivingusewould break the disposability guarantee K2 exists to enforce. The bridge is testing-only.Test
pnpm --filter start-api-todo test:integration— 6 passed🤖 Generated with Claude Code