Writing TypeScript Tests in llxprt-code
Distilled from dev-docs/RULES.md. When in doubt, read RULES.md in full — it is
the source of truth for development guidelines.
Core principle: TDD is mandatory
Every line of production code must be written in response to a failing test.
No exceptions.
Red-Green-Refactor, followed strictly:
- RED: Write a failing test for the next small behavior.
- GREEN: Write ONLY enough code to make the test pass.
- REFACTOR: Only if it improves clarity.
- COMMIT: Feature + tests together; refactoring separately.
Stack rules (non-negotiable)
Bun + bun:test ONLY. Never create Vitest or Node test suites, and never add
new .js files — everything is TypeScript run with bun.
TypeScript strict mode: no any (use unknown with type guards), no type
assertions (use type predicates), explicit return types.
Import pattern (bun:test re-exports vi, mock, and Mock):
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type Mock,
} from 'bun:test';
Prefer immutable data in tests and fixtures ({ ...cart, items: [...cart.items, item] });
never mutate shared fixtures between tests.
Test behavior, not implementation
✅ Test:
- Public API behavior
- Input → Output transformations
- Edge cases and error conditions
- Integration between units
- Schema validation
❌ Never test:
- Implementation details
- Private methods
- Third-party libraries
- Mock interactions
❌ Never enshrine bugs as specification: do not write a passing test that asserts
incorrect behavior, even if that is what the code currently does. If you
discover a bug while writing tests: (1) file an issue or ask the user, (2) write
a failing test that asserts the CORRECT behavior, (3) fix the production code so
the test passes. A suite that encodes bugs as passing tests is worse than no
tests — it actively prevents future fixes.
Test structure
- describe: feature/component name.
- it: specific behavior in plain English.
- Arrange-Act-Assert: clear sections; one behavior per test.
- DRY setup: never copy-paste identical beforeEach/afterEach boilerplate
(e.g. temp-dir creation, cleanup) across describe blocks. Extract a shared
helper that wires the lifecycle hooks (e.g. a
useTempDir() helper that
registers beforeEach/afterEach internally and returns a lazy accessor).
Repeating 5+ lines of identical setup in N describe blocks is a maintenance
hazard; one line of shared setup per describe block is the target.
File conventions
- Tests are TypeScript, co-located with the code under test.
*.test.ts is the dominant pattern in this repo; *.spec.ts and
__tests__/ directories are also in use — match the existing pattern of the
neighboring tests before creating a new file.
- File names: kebab-case.
- Run a single file with
bun test path/to/file.test.ts; the full suite is
npm run test.
Mock hygiene: no mock theater
The fundamental rule: you cannot test a component by mocking that component.
Mock decision tree
Is it the component you're testing?
├─ Yes → [ERROR] NEVER MOCK IT
└─ No → Is it doing the core work being tested?
├─ Yes → [ERROR] DON'T MOCK IT
└─ No → Is it infrastructure (FS, network, DB)?
├─ Yes → [OK] OK to mock
└─ No → Is it completely unrelated to the test?
├─ Yes → [OK] OK to mock
└─ No → WARNING: Probably shouldn't mock
Prohibited mock patterns
- Self-mocking — mocking the component under test. If you mock EmojiFilter
to test EmojiFilter, you are testing the mock, not the component.
- Direct-value mock — a mock that returns exactly the output the test
expects. The test is worthless: it can never fail for the right reason.
- Mock verification — asserting a mock was called
(
expect(mockFn).toHaveBeenCalledWith(...)) proves nothing about real code.
- Mirror-echo assertion — configuring a stub with a literal and asserting
that same literal back (
.mockReturnValue('JIT_MARKER') …
expect(policy.marker).toBe('JIT_MARKER')). Unless producing that literal
IS the behavior under test, the test asserts its own configuration and can
never fail for the right reason. Assert a derived property instead —
ordering, precedence, counts, aggregation — with literals on the input side
only.
Also watch for: the mock chain (A calls MockB calls MockC — no real code tested)
and mocks with complex implementations (if the mock has the logic, test the real
code instead).
Allowed mock patterns
- Infrastructure mocking — mock filesystem, network, databases (not
business logic); instantiate the REAL component; assert real transformations
through the real code paths.
- Irrelevant service mocking — mock services unrelated to the behavior
under test (auth, logging), while the component under test is real.
- Test data builders — build input data; never mock behavior.
The litmus test
After writing a test, all four answers must be the "good" one:
- If I delete the real implementation, will this test fail? (If NO: worthless)
- If I break the real implementation, will this test catch it? (If NO: worthless)
- Am I testing my mock or my code? (If MOCK: worthless)
- Could I replace the component with
return 'expected' and pass? (If YES: worthless)
Before submitting
- All tests pass (
npm run test).
- No TypeScript errors (
npm run typecheck).
- No linting warnings (
npm run lint).
- No console.logs or debug code left behind.
- Every production line you touched is covered by behavior tests.
- Self-check files you touched with the AST test-audit scanner:
bun scripts/test-audit/scan.ts — no new MOCK_MIRROR, ALWAYS_TRUE,
SELF_CONFIRMING, or NO_ASSERT findings may appear on them. The scanner
runs over the full corpus and writes findings.tsv + file-stats.tsv to
the output dir (defaults to tmp/test-audit). To compare against the
baseline, run it once on main with a custom output dir, then on your
branch with a different output dir, and diff the TSVs:
bun scripts/test-audit/scan.ts tmp/scan-main vs
bun scripts/test-audit/scan.ts tmp/scan-branch, then
diff tmp/scan-main/findings.tsv tmp/scan-branch/findings.tsv.
Note: the scanner compares exact stable literals (not substrings) between
mock configurations and assertion arguments. A MOCK_MIRROR finding on a
test that verifies a transformation (e.g., asserting 'A B' where the
stub returns 'A' and 'B' separately) is a false positive — the
assertion literal does not exactly match any single mock literal. Inspect
the exact positive equality that triggered the finding before dismissing
it. Disjoint-source and leakage assertions (e.g., not.toContain('X'))
are not mirror echoes and will not trigger MOCK_MIRROR.
1---2name: typescript-test-writing3description: Use this skill when writing or modifying tests in the llxprt-code repository. Covers mandatory TDD, behavioral testing, bun:test conventions and file naming, mock hygiene (no mock theater), and what never to test. Distilled from dev-docs/RULES.md, which remains the source of truth.4---56# Writing TypeScript Tests in llxprt-code78Distilled from dev-docs/RULES.md. When in doubt, read RULES.md in full — it is9the source of truth for development guidelines.1011## Core principle: TDD is mandatory1213Every line of production code must be written in response to a failing test.14No exceptions.1516Red-Green-Refactor, followed strictly:17181. **RED**: Write a failing test for the next small behavior.192. **GREEN**: Write ONLY enough code to make the test pass.203. **REFACTOR**: Only if it improves clarity.214. **COMMIT**: Feature + tests together; refactoring separately.2223## Stack rules (non-negotiable)2425- Bun + `bun:test` ONLY. Never create Vitest or Node test suites, and never add26 new `.js` files — everything is TypeScript run with bun.27- TypeScript strict mode: no `any` (use `unknown` with type guards), no type28 assertions (use type predicates), explicit return types.29- Import pattern (bun:test re-exports `vi`, `mock`, and `Mock`):3031 ```typescript32 import {33 describe,34 it,35 expect,36 beforeEach,37 afterEach,38 vi,39 type Mock,40 } from 'bun:test';41 ```4243- Prefer immutable data in tests and fixtures (`{ ...cart, items: [...cart.items, item] }`);44 never mutate shared fixtures between tests.4546## Test behavior, not implementation4748✅ Test:4950- Public API behavior51- Input → Output transformations52- Edge cases and error conditions53- Integration between units54- Schema validation5556❌ Never test:5758- Implementation details59- Private methods60- Third-party libraries61- Mock interactions6263❌ Never enshrine bugs as specification: do not write a passing test that asserts64incorrect behavior, even if that is what the code currently does. If you65discover a bug while writing tests: (1) file an issue or ask the user, (2) write66a failing test that asserts the CORRECT behavior, (3) fix the production code so67the test passes. A suite that encodes bugs as passing tests is worse than no68tests — it actively prevents future fixes.6970## Test structure7172- **describe**: feature/component name.73- **it**: specific behavior in plain English.74- **Arrange-Act-Assert**: clear sections; one behavior per test.75- **DRY setup**: never copy-paste identical beforeEach/afterEach boilerplate76 (e.g. temp-dir creation, cleanup) across describe blocks. Extract a shared77 helper that wires the lifecycle hooks (e.g. a `useTempDir()` helper that78 registers beforeEach/afterEach internally and returns a lazy accessor).79 Repeating 5+ lines of identical setup in N describe blocks is a maintenance80 hazard; one line of shared setup per describe block is the target.8182## File conventions8384- Tests are TypeScript, co-located with the code under test.85- `*.test.ts` is the dominant pattern in this repo; `*.spec.ts` and86 `__tests__/` directories are also in use — match the existing pattern of the87 neighboring tests before creating a new file.88- File names: kebab-case.89- Run a single file with `bun test path/to/file.test.ts`; the full suite is90 `npm run test`.9192## Mock hygiene: no mock theater9394**The fundamental rule: you cannot test a component by mocking that component.**9596### Mock decision tree9798```99Is it the component you're testing?100├─ Yes → [ERROR] NEVER MOCK IT101└─ No → Is it doing the core work being tested?102 ├─ Yes → [ERROR] DON'T MOCK IT103 └─ No → Is it infrastructure (FS, network, DB)?104 ├─ Yes → [OK] OK to mock105 └─ No → Is it completely unrelated to the test?106 ├─ Yes → [OK] OK to mock107 └─ No → WARNING: Probably shouldn't mock108```109110### Prohibited mock patterns1111121. **Self-mocking** — mocking the component under test. If you mock EmojiFilter113 to test EmojiFilter, you are testing the mock, not the component.1142. **Direct-value mock** — a mock that returns exactly the output the test115 expects. The test is worthless: it can never fail for the right reason.1163. **Mock verification** — asserting a mock was called117 (`expect(mockFn).toHaveBeenCalledWith(...)`) proves nothing about real code.1184. **Mirror-echo assertion** — configuring a stub with a literal and asserting119 that same literal back (`.mockReturnValue('JIT_MARKER')` …120 `expect(policy.marker).toBe('JIT_MARKER')`). Unless producing that literal121 IS the behavior under test, the test asserts its own configuration and can122 never fail for the right reason. Assert a derived property instead —123 ordering, precedence, counts, aggregation — with literals on the input side124 only.125126Also watch for: the mock chain (A calls MockB calls MockC — no real code tested)127and mocks with complex implementations (if the mock has the logic, test the real128code instead).129130### Allowed mock patterns1311321. **Infrastructure mocking** — mock filesystem, network, databases (not133 business logic); instantiate the REAL component; assert real transformations134 through the real code paths.1352. **Irrelevant service mocking** — mock services unrelated to the behavior136 under test (auth, logging), while the component under test is real.1373. **Test data builders** — build input data; never mock behavior.138139### The litmus test140141After writing a test, all four answers must be the "good" one:1421431. If I delete the real implementation, will this test fail? (If NO: worthless)1442. If I break the real implementation, will this test catch it? (If NO: worthless)1453. Am I testing my mock or my code? (If MOCK: worthless)1464. Could I replace the component with `return 'expected'` and pass? (If YES: worthless)147148## Before submitting149150- All tests pass (`npm run test`).151- No TypeScript errors (`npm run typecheck`).152- No linting warnings (`npm run lint`).153- No console.logs or debug code left behind.154- Every production line you touched is covered by behavior tests.155- Self-check files you touched with the AST test-audit scanner:156 `bun scripts/test-audit/scan.ts` — no new MOCK_MIRROR, ALWAYS_TRUE,157 SELF_CONFIRMING, or NO_ASSERT findings may appear on them. The scanner158 runs over the full corpus and writes `findings.tsv` + `file-stats.tsv` to159 the output dir (defaults to `tmp/test-audit`). To compare against the160 baseline, run it once on `main` with a custom output dir, then on your161 branch with a different output dir, and diff the TSVs:162 `bun scripts/test-audit/scan.ts tmp/scan-main` vs163 `bun scripts/test-audit/scan.ts tmp/scan-branch`, then164 `diff tmp/scan-main/findings.tsv tmp/scan-branch/findings.tsv`.165 Note: the scanner compares exact stable literals (not substrings) between166 mock configurations and assertion arguments. A MOCK_MIRROR finding on a167 test that verifies a transformation (e.g., asserting `'A168B'` where the169 stub returns `'A'` and `'B'` separately) is a false positive — the170 assertion literal does not exactly match any single mock literal. Inspect171 the exact positive equality that triggered the finding before dismissing172 it. Disjoint-source and leakage assertions (e.g., `not.toContain('X')`)173 are not mirror echoes and will not trigger MOCK_MIRROR.