Systematically add unit test coverage to a project's P0 US with NONE/PARTIAL/PASS-E2E-only
test status. Trigger when user says "test 未做的都要做", "補 unit test", "P0 US 全部要有
test", "紅線 12 守到", or asks to fix QA-TRACKER.md gaps. Class-level — covers any backend
(Elysia/Express/Hono/Fastify/NestJS) or any TypeScript project with inline route logic.
The 3-step playbook: parse QA-TRACKER + count P0 US, for each P0 US decide derive pure
helper vs integration test vs DEFERRED (raise blocker EARLY for the third bucket), then
commit series + tracker sync. Pairs with regression-guard, tech-debt-register, and
code-review-pipeline.
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Systematically add unit test coverage to a project's P0 US with NONE/PARTIAL/PASS-E2E-only
test status. Trigger when user says "test 未做的都要做", "補 unit test", "P0 US 全部要有
test", "紅線 12 守到", or asks to fix QA-TRACKER.md gaps. Class-level — covers any backend
(Elysia/Express/Hono/Fastify/NestJS) or any TypeScript project with inline route logic.
The 3-step playbook: parse QA-TRACKER + count P0 US, for each P0 US decide derive pure
helper vs integration test vs DEFERRED (raise blocker EARLY for the third bucket), then
commit series + tracker sync. Pairs with regression-guard, tech-debt-register, and
code-review-pipeline.
When docs/QA-TRACKER.md shows many P0 US at NONE / PARTIAL / PASS-E2E-only, this is the playbook to bring them all to PASS-UNIT (or correctly labeled DEFERRED) without producing fake output.
Triggered by David: "另外,QA-TRACKER.md 中嘅 test 未做的,都要做一下吧,盡快把所有問題都找出來" (2026-06-08, pm-system) — pushed 22 P0 US from NONE/PARTIAL to PASS-UNIT, +288 unit tests, 0 source code changes, 0 regressions.
When to load
User says "補 unit test", "test 未做的都要做", "P0 US 全部要有 test", "紅線 12 守到"
docs/QA-TRACKER.md has many NONE / PARTIAL rows
Sprint retro flags "0 unit tests" / "low P0 US coverage" as a debt
Before any "ship" / "prod deploy" / 紅線 12 enforcement moment
When NOT to load
Single test debug (use bun test --bail, read failing test output)
Bug-specific regression (use regression-guard — write 1 RG-XXX test, not a coverage push)
TDD on a new feature (use proper red-green-refactor, not bulk coverage push)
3-Step Playbook
Step 1 — Recon: parse QA-TRACKER + count P0 US
Don't eyeball the table — 50+ US rows, easy to misread. Use the bundled parser, but verify it matches YOUR tracker's column layout first (the parser was written for 2026-06-08 pm-system layout, see Pitfall #17 below).
The MD table has a leading empty cell (because the row starts with | | US-1.1 | ... — the first | is followed by an empty cell before the US ID). So a 9-column table → cells[0] is empty, cells[1] is US ID, cells[2] is title, etc. Count your columns; don't guess.
Fallback path when execute_code is slow/blocked (2026-06-09 pm-system lesson): if the bundled parser or inline Python isn't working AND execute_code is hanging, fall back to plain awk via terminal(). This session's actually-working pattern:
awk -F'|''NR>=15 && /US-/ {
gsub(/^ +| +$/,"",$2); # US ID
gsub(/^ +| +$/,"",$3); # Title
gsub(/^ +| +$/,"",$7); # Test Status (pm-system convention)
print $2"|"$3"|"$7
}' docs/QA-TRACKER.md > /tmp/qa.txt
awk -F'|''{
if ($3=="NONE") none++
else if ($3 ~ /PASS/) pass++
else other++
} END { print "PASS: "pass"\nNONE: "none"\nOther: "other }' /tmp/qa.txt
Rule: whichever parser path you take, the first command must print a sanity number (e.g. Total P0: 22). If it prints 0 or errors, fix the column indices before reporting the breakdown to David. Reporting a fake zero is 紅線 1 territory.
Then ask David scope question — do NOT skip this (紅線 49 multi-task pushback rule):
「未做嘅 test 全部做」有幾闊?
A) 全部 P0 US 補完 unit(紅線 16 嚴格) — full push, may be 6-12 commits + 1-3 sprints
B) 4 個 PARTIAL + 2 個 🔴 (7 個) 補到 PASS — targeted push
C) 只做紅線 12 必須嘅 🔴 — minimal ship-blocker fix
D) 你揀 — list specific US
David almost always picks A when prompted with options, but he wants the choice.
Step 1.5 — Per-US audit table (Sprint 10+ lesson, NOT optional for ≥3 US)
After David confirms scope but BEFORE writing any test, emit a per-US audit table so David can sanity-check the plan in one read. Skip only when scope = 1-2 US (直接做都得)。對於 6+ US 嘅 sprint push,冇呢個 table = David 喺第一個 commit 嘅 review stage 就返轉頭問 6 條 scope question,浪費 1 round-trip。
Table columns(必要 6 欄,缺一不可):
| US | 範圍 | Endpoint / 對應 | 已有 test | 補 test 範圍 | 估時 | 備註 |
Per row 填法:
範圍:1 行描述個 US 做咗乜(e.g. "WorkLogs 部門/用戶篩選")
Endpoint / 對應:backend/src/routes/X.ts GET/POST/PUT 行號 + frontend component name
For EACH P0 US in need_unit, classify into one of three tiers:
Tier
Heuristic
Cost
Coverage
Derive (cheapest)
Route has inline arrow fns (e.g. const canX = (...) => ... or direct if (!user) return 403 inside .post()), OR uses exported helpers, OR is pure CRUD (validation, default values, status enum)
~30 min/US, 0 source changes
Tests 純 logic; relies on copy-paste staying in sync
Integration (Path X)
Route has stateful streaming (SSE, WebSocket, OpenAI tool-calling, multipart upload) but logic IS inspectable: function boundary + structured wire format
Tests real fetch wire format + WS lifecycle + SSE event boundary parsing — the bugs pure unit tests miss
DEFERRED (correct call)
Route is >500 LOC AND in-memory side effects only accessible via internal closure (e.g. module-level WS Map not exported, or business logic entangled with prisma writes)
Cannot do well
Mark in tracker, do NOT fake
Path X (Integration test) — when to pick this tier (from 2026-06-08 pm-system Sprint 3):
Route file uses fetch() against external API (OpenAI, Stripe, internal microservices)
Route has SSE (text/event-stream) or WebSocket connection lifecycle
Route constructs Response with structured wire format (JSON / protobuf / SSE events)
Function can be exported and called with a mock fetch + mock Request object
Heuristic for "too complex to derive, but still Integration-testable":
Look for fetch(, new ReadableStream, ws.send(, WebSocketServer, stream: → likely Integration tier
Look for module-level Map<, WebSocket> with no accessor → if you can export const getActiveConnections or add a getter, still Integration; otherwise DEFERRED
File > 500 LOC + SSE/WS → can still do Integration (1723 LOC chat.ts was Integration-tier in Sprint 3)
If 2+ US will be DEFERRED, RAISE BLOCKER EARLY (before writing any test). Don't write 22 derive-style tests and then ask. Example prompt:
A) 寫 30+ 個 integration test 過 SSE event boundary + WS 生命週期(mock fetch + mock prisma)
B) DEFER 全部,維持 ship-blocker 紅
C) 8.7 LLM config (81 行 CRUD) + 9.1 補完 claim/release + retro doc(降 scope)
David 揀 A。Path X rule:user 揀 D 但 D literal 做唔到,raise blocker + 提修正版 ≠ 偷工減料,係 raise technical impossibility。如果用戶揀 B(堅持 D literal),唔好悶頭做 fake test,要再解釋風險:「會做 fake output,紅線 1 fake data 違規」。
Heuristic for "DEFERRED" 真正 trigger (2026-06-08 update):
Step 3 — Write test file per P0 US (or per epic), commit series, sync tracker
File naming: one test file per route file, NOT per US. e.g. auth.test.ts covers US-1.1, 1.2, 1.3 (and change-password bonus). This matches permission.test.ts precedent.
Last commit: docs(qa,retro): Sprint N P0 Unit Test Push — 紅線 12/16 X%→Y% — bundles tracker + retro doc, single commit.
Derive Helper Pattern (核心 technique)
The technique that made the 22 P0 US push possible. Steps:
Read source — open backend/src/routes/<route>.ts, identify inline arrow functions or validation logic inside route handlers.
Copy verbatim — paste the function into the test file at the top, with comment // 從 <route>.ts line N-M derive. NEVER paraphrase — keep logic identical so test = spec.
Add type signature — strip TypeScript types from source if too coupled, or import the type. Add AuthUser / ProjectLite etc. as test-local types.
Test the happy path + edges — at minimum: null/empty, valid input, edge cases (boundary values, special characters, business invariants).
Document the source line range in the file header comment so future maintainers can verify the test still matches source.
Why this works for Elysia / Express / Hono: route handlers are largely permission gates + validation + Prisma calls. The first two are pure functions; only the Prisma calls are stateful. So even on a 200-LOC route, you can extract 50-100 lines of pure logic for testing.
What does NOT work (skip these):
LLM prompt construction (string templates with substitution)
Playwright 唔可以 mock 另一個 process 嘅 fetch — backend 開咗,LLM 嘅 fetch 喺 backend process 內打,Playwright 喺瀏覽器 side,冇辦法攔截
真 OpenAI call 要 API key,CI 唔可以 assume 有 key
真 OpenAI 嘅 response 唔 deterministic,test 唔 stable
Path X 喺 < 1 秒 跑完,vs E2E 啟動 docker 幾分鐘
Path X 100% 喺 host 跑(無 docker),適合 CI
完整 template 喺 references/integration-test-template.ts(Sprint 3 抽出嚟嘅 working template)。
State Machine Pattern (status transitions)
For any route with status enum + transitions (Tasks/Requirements/Bugs/Orders/Tickets):
Identify the status enum from source (e.g. pending | in_progress | completed | cancelled)
Derive transition graph from business rules (e.g. completed is terminal, cancelled can revive to pending)
Test ALL transitions, not just the happy path
Don't extract from source — derive from business intent, document the assumption
Why: source usually has implicit transitions (e.g. if (task.status === 'in_progress') return 400). Tests make the implicit explicit. Future refactor that breaks the invariant = test fails.
Bun-Specific Tooling
Use bun test (NOT jest / vitest) — Bun 1.2+ has built-in test runner
bun test <file> for single-file debug, bun test for full suite
bun test --bail to stop at first failure
Add pretest: bunx prisma generate hook in package.json so host bun run test works without docker (見 pitfalls)
Pitfalls
DON'T produce fake tests for complex state — if you can't derive a pure helper, write nothing and mark DEFERRED. David's "fake output" red line is non-negotiable.
DON'T skip the scope question — going from 0 tests to "all P0 covered" without David confirming scope = scope creep = waste 6+ hours
DON'T eyeball the tracker — execute_code parse the markdown; 50+ US table is too easy to misread
DON'T combine multiple test files in 1 commit — reviewability collapses. 1 file = 1 commit always.
DON'T forget pretest hook — Prisma + bun + host test env is a known trap. pretest: bunx prisma generate in package.json saves 1-2 hours of "tests fail in host but pass in docker" debug.
DON'T ship "PASS-UNIT" if helper is just a re-typed signature — the test must EXERCISE the logic with assertions. A test that just calls canCreateRequirement({admin}) and asserts true is fine; one that just expect(typeof canCreateRequirement).toBe('function') is fake.
DO verify source verbatim — when extracting a helper, copy the exact condition structure. If you "improve" it during extraction, the test no longer pins the source.
DO track what you DIDN'T cover — write a TD-XXX entry in docs/TECH-DEBT.md for every DEFERRED US with the integration test scope estimate. Otherwise next sprint will re-discover the gap.
DO include regression guards — if your P0 US touches a security boundary (auth, RBAC, key handling), add a parseAuthToken / redactLLMConfig / isValidMemberRole style guard with the assertion "rejects X" as the primary test. The negative case is what catches refactor regressions.
DO emit "📍 progress 點 N/M" between phases — when doing 8+ phases in one session, the agent-stuck-recovery skill says emit at every phase boundary so David knows the agent hasn't hung. This is a 紅線 19 thing.
DO raise blocker EARLY for Path X — when user 揀 option D 但 option D literal 做唔到(mock 唔到另一個 process 嘅 fetch),立即 raise blocker + 提修正版(Path X = backend in-process integration test + wire-up E2E combo)。唔好悶頭做 fake Playwright mock。Sprint 3 lesson:David 揀咗「做您嘅推薦」= 認受修正版。
DON'T await app.listen(0) in test env — Bun 嘅 random port 喺 test runner 入面有 race condition,test 之間會撞 port。Fix:直接 call app.handle(new Request('http://test/...')),Elysia 內部 dispatch,bypass listen。Sprint 3 全程冇 listen,純 in-process。
DON'T trust a parser that returns 0 P0 US — the bundled qa-tracker-parser.py (and the inline snippet above) assume a 7-column table with "P0" in column 2 and Test Status in column 5. pm-system's actual tracker is 9 columns with empty first cell, no "P0" column, Test Status in column 6. 2026-06-09 lesson: parser returned 0, agent (me) tried 3 execute_code fixes guessing column indices (5, 6, 7 — all wrong or fragile), all timed out or KeyError: 0 (because terminal() returns dict, not list), then fell back to plain awk via terminal(). Fix: (a) ALWAYS verify parser output with a sanity number first — if 0, the parser is wrong, not the tracker; (b) read the MD header row directly to count columns before indexing; (c) when execute_code is blocked/timing out, terminal() + awk -F'|' is the working fallback — terminal() returns a dict with output key, NOT a list. (d) 9-column MD table trick: first cell is empty (| | US-1.1 | ...), so cells[1] = US ID, cells[2] = title, cells[6] = Test Status (verified in pm-system 2026-06-09).
DON'T use terminal() result as a list — terminal(command) returns {"output": "...", "exit_code": N}, not a list. result[0] → KeyError: 0. Fix: use result.get("output", "") or unpack output, exit_code = terminal(cmd)-style. (Discovered 2026-06-09 pm-system QA-TRACKER count task — wasted 2 attempts on KeyError before catching it.)
Deliverables Checklist
When done, you should have:
N new .test.ts files (one per route), each with from <route>.ts line N-M derive comments
N git commits, one per test file, with consistent commit message pattern
docs/QA-TRACKER.md updated: every covered US row's Test Status, metrics table, change history
docs/TECH-DEBT.md updated: TD-XXX entry for every DEFERRED US
docs/retros/YYYY-MM-DD-sprint-N-test-push.md retro doc: scope, what covered, what DEFERRED + why, metrics
Final docs(qa,retro): ... commit bundling tracker + retro
bun test exit 0 from host (pretest hook handles Prisma generate)
Push to origin (last step, not auto — David reviews first)
What "done" looks like (real numbers from 2026-06-08)
Before
After
P0 US coverage
28% (8/29)
79% (23/29)
Unit tests
45
333
Test files
5
14
🔴 ship-blocker
2
0
Source code changes
—
0 lines (pure test addition)
New deps
—
0
Regressions
—
0
Anti-patterns (do NOT do)
❌ "Add 100 tests by mocking Prisma" → fragile, slow, tests Prisma not your code(但 mock Prisma 喺 Path X 嘅 LLM/WS 場景係 OK 嘅 — 因為 Prisma 唔係主角,我哋要 test 嘅係 wire format,Prisma 純粹係 noise,see references/integration-test-template.ts)
❌ "Test the route handler with app.handle(new Request(...))" → requires real DB or extensive mock setup, slow(更正:對於 derive tier 嘅純 logic helper,呢個係 overhead,直接 import 嗰個 helper 仲好;但 Path X 嘅 SSE/WS/LLM 場景,app.handle(new Request(...)) 配 mock fetch 係 valid pattern,只需要 5-10 個 mock module 唔需要真 DB)
❌ "Copy route handler logic into test file and assert" → duplicates source, drift inevitable
❌ "Write E2E instead because unit is hard" → scope creep, doesn't satisfy 紅線 12(更正:Path X 嘅 SSE/WS 場景,backend in-process integration test + 1 個 wire-up E2E 嘅 combo 係 correct,唔係 scope creep)
❌ "Use as any to bypass type errors in derive" → test no longer pins source, gives false confidence
❌ "Test private functions by reflection / unsafe cast" → brittle, breaks on refactor
❌ "Skip Path X 因為 derive 唔到就 DEFERRED" → false negative,睇下 wire boundary 啱唔啱(Path X 啱嘅 case 唔好走雞)
tech-debt-register — for logging DEFERRED US as TD-XXX
prisma-sqlite-bun-setup — for the pretest: bunx prisma generate recipe (and Prisma 5/7 gotchas)
bun-env-file-for-dev — for .env loading issues during test
prisma-relation-debugging — if helper extraction surfaces a Prisma type issue
agent-stuck-recovery — for the "emit progress between phases" pattern
qa-tracker (in SOUL.md cross-ref) — the tracker that drives this whole skill
Reference (real session evidence)
pm-system 2026-06-08 Sprint 2 — 22 P0 US pushed NONE/PARTIAL → PASS-UNIT via Derive tier, +288 tests, 0 source changes. Full retro: docs/retros/2026-06-08-sprint-2-p0-unit-test-push.md
9 test files added in Sprint 2: auth.test.ts, projects.test.ts, requirements.test.ts, bugs.test.ts, roles.test.ts, agents-create.test.ts, agents-claim.test.ts, tasks-extended.test.ts, wikis.test.ts, llm-config.test.ts
3 US flagged DEFERRED in Sprint 2 retro: US-8.1, 8.2, 9.3 — all involve chat.ts (1787 LOC) or agent/runtime.ts (645 LOC)
Sprint 2 early blocker raise: at phase 6 (LLM/WS) I stopped and asked David to pivot to US-8.7 + US-9.1 + retro instead of faking US-8.1/8.2/9.3 — saved 1-2 hours of fake output
pm-system 2026-06-08 Sprint 3 — 3 US closed via Path X (Integration tier): US-8.1 + US-8.2 + US-9.3, +39 integration tests, 5 source files (1-line export keyword additions only), 0 regressions. Full retro: docs/retros/2026-06-08-sprint-3-act14-15-closure.md
pm-system 2026-06-09 QA-TRACKER count task — parser column-index trap + execute_code fallback to awk: tried to count PASS/NONE/Other on 81-US tracker, bundled parser silently returned 0 (assumes 7-col layout, actual is 9-col + no P0 column). Spent 3 attempts on execute_code guessing column indices (5→6→7, all wrong) + 1 KeyError: 0 on terminal()[0] (it's a dict). Final working path: awk -F'|' with gsub(/^ +| +$/,"",$2/$3/$7) + terminal().get("output") access. Lesson embedded in Pitfall #16 + #17 + Step 1 fallback path.