@boopkit/cap-testing 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -12,12 +12,52 @@ metadata:
|
|
|
12
12
|
**Functional testing, not ceremony.** Tests prove code works — not that mocks were wired correctly. Every test should exercise real logic. Type-checking or compilation alone is never sufficient. Be pessimistic about your assumptions: if you think "this should never fail," write a test that verifies it doesn't.
|
|
13
13
|
|
|
14
14
|
- **Observe the failure first.** A plausible explanation for repeated failures is still a hypothesis until you see success. Always verify the expected *positive* outcome actually occurs (e.g., `cache_hit: true`, not just "no errors").
|
|
15
|
-
- **Mocks are a last resort**, acceptable only for genuinely external dependencies (third-party HTTP, email providers) or to isolate complex decision logic (error classification, retry semantics). If it doesn't hit a real store, call it a unit test — not an "integration test with mocks."
|
|
15
|
+
- **Mocks are a last resort**, acceptable only for genuinely external dependencies (third-party HTTP, email providers), to pin a nondeterministic input the test is not asserting on (a model/LLM call in a plumbing test — see "Stub the model, not the machinery"), or to isolate complex decision logic (error classification, retry semantics). If it doesn't hit a real store, call it a unit test — not an "integration test with mocks."
|
|
16
16
|
- **Data-driven coverage for config tables.** Code backed by configuration (pattern tables, route maps, enum mappings) must have tests that exercise *every entry*, not a hand-picked subset. Include a count sanity check (`expect(records.length).toBeGreaterThan(N)`) so an empty response doesn't silently pass.
|
|
17
17
|
- **Undocumented assumptions are spec gaps, not test targets.** When the spec is silent on a behavior (e.g., can a user have multiple sessions? does logout delete or expire state?), that's a design decision to resolve — not something to test around. **Resolve the ambiguity first** (update the spec or get a decision), **then** write the test encoding the decided behavior. If you're writing tests before code (TDD), a spec gap blocks you — don't guess the intent and test your guess. If you discover the gap while reviewing existing code, file a spec issue and name the test to surface the question (e.g., `'login creates additional session token — multi-device support'` not `'login works'`).
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
+
## Assert the effect, not the envelope
|
|
22
|
+
|
|
23
|
+
**A test must assert that the state actually changed — not just the shape of the response.** The response envelope (status code, redirect target, `{ ok: true }` body) is what the handler *says* happened; the effect (a row transitioned, a record was created, a balance moved) is what *actually* happened. Assert the effect.
|
|
24
|
+
|
|
25
|
+
This matters most when **the success path and a failure path can produce the same envelope.** A handler that redirects to the same place on success and on silently-rejected input will pass any test that checks only the redirect — on both paths. The test proves nothing about which path ran.
|
|
26
|
+
|
|
27
|
+
**Neutral example.** A handler that submits a form redirects to `/items` both when the item is accepted *and* when validation silently drops it (no error surfaced). A test asserting `302 -> /items` is green on both paths, so a regression that makes every submission silently bounce still passes. The test that also reads the item back and asserts it reached the `submitted` state is the one that catches the regression.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
// Weak — green on success AND on a silent bounce
|
|
31
|
+
expect(res.status).toBe(302);
|
|
32
|
+
expect(res.headers.location).toBe('/items');
|
|
33
|
+
|
|
34
|
+
// Strong — asserts the state change the request was supposed to cause
|
|
35
|
+
expect(res.status).toBe(302);
|
|
36
|
+
const item = await store.get(id);
|
|
37
|
+
expect(item.state).toBe('submitted'); // the effect
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Rule:** whenever success and failure share a response shape, assert the observable state change. The envelope alone is not evidence the operation happened.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quarantine convention — a skipped test must cite a tracking ticket
|
|
45
|
+
|
|
46
|
+
**"Known failing" must be a bounded, tracked, VISIBLE state — never ambient noise.** A suite that runs chronically red carries zero signal: a genuine new failure hides among the tolerated ones. Quarantine-with-a-ticket converts "N flaky failures" into "N tracked exceptions", so any *unexpected* red is a real signal again.
|
|
47
|
+
|
|
48
|
+
**Rule.** Every statically-skipped test (`describe.skip`, `it.skip`, `test.skip`, `xit`, `xdescribe`, `xtest`) must carry one of these markers, on the same line or within the two comment lines directly above it:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
// QUARANTINE: <issue-id> <reason> — known-failing, tracked by an issue.
|
|
52
|
+
// SKIP-OK: <reason> — intentional non-failure skip (opt-in / env-gated / platform-specific).
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A skip carrying neither a ticket reference nor a `SKIP-OK:` reason is a convention violation.
|
|
56
|
+
|
|
57
|
+
**Enforcement.** A guard test scans every test file and fails when a skip lacks a marker, so it runs as part of the normal suite with no extra tooling. When you must quarantine, file (or reuse) a tracking issue first, then add the marker — don't skip silently.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
21
61
|
## Generic Testing Approach
|
|
22
62
|
|
|
23
63
|
These principles apply across all subsystems.
|
|
@@ -60,6 +100,18 @@ Mocks are helpful for isolating orchestration logic, but their limitations must
|
|
|
60
100
|
| Third-party HTTP (email, OAuth) | Adjacent services you own (use real `getServices()`) |
|
|
61
101
|
| `Date.now()` for deterministic assertions | Database queries (use real Postgres/CH) |
|
|
62
102
|
| Complex branching inputs | The service under test's own methods |
|
|
103
|
+
| A model/LLM call when the test verifies your *system's handling* of its output (see "Stub the model, not the machinery") | A model/LLM call when the test's *subject* is the model's behavior — evals, agent decision quality |
|
|
104
|
+
|
|
105
|
+
### Stub the model, not the machinery
|
|
106
|
+
|
|
107
|
+
This looks like it contradicts "mocks are a last resort," but it is the same principle applied correctly. The reason not to mock a boundary is that mocking hides the boundary the test exists to exercise — the store, the queue, the serialization, the contract between components — where the real bugs live. A model/LLM call in a test that is *about the plumbing* is not that boundary. It is a nondeterministic **input source**.
|
|
108
|
+
|
|
109
|
+
So when you test code that drives a model, first decide what the test actually verifies:
|
|
110
|
+
|
|
111
|
+
- **Verifying your system's handling of a model's output** — the routing, the persistence, the downstream effects, the contracts between components. Here the model is an input. Give it a **fixed, representative output** so the test is deterministic, and keep every real boundary it touches (database, message queue, serialization, the producer/consumer handoff) **real**. This is not a dumb mock: you are pinning a nondeterministic input so the boundaries that carry bugs get exercised for real. A live model call here buys nothing and actively harms the test — it now goes red when the model merely phrases things differently today, which is noise, not signal, and it makes an integration suite flaky and slow.
|
|
112
|
+
- **Verifying the model's behavior itself** — an eval, a prompt-quality check, "does the agent choose the right tool." Here the model *is* the boundary under test, so it must be real. Stubbing it would defeat the test.
|
|
113
|
+
|
|
114
|
+
The question to ask: **"If this test goes red, do I want that to mean the model said something different today?"** If no, stub the model and keep the real stores. If yes, use the real model.
|
|
63
115
|
|
|
64
116
|
### Data-driven config coverage
|
|
65
117
|
When code is backed by a config table (patterns, routes, flags, enums):
|
|
@@ -7,7 +7,7 @@ metadata:
|
|
|
7
7
|
# Testing: Express App (Web & APIs)
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
Parent skill: [testing
|
|
10
|
+
Parent skill: [`boop-testing-testing`](../boop-testing-testing/SKILL.md) (philosophy, generic approach)
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -20,63 +20,68 @@ npx jest tests/services/foo --no-coverage # Pattern match
|
|
|
20
20
|
npx jest tests/services/foo.test.ts -t "handles replay" # By test name
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
**Prerequisites**:
|
|
23
|
+
**Prerequisites**: integration tests run against the project's real backing services rather than mocks, so whatever the app talks to at runtime — the database first of all — has to be reachable from the machine running the tests. Which services those are depends on the capabilities the project has installed; its README and environment template are the authority.
|
|
24
24
|
|
|
25
25
|
## Test File Layout
|
|
26
26
|
|
|
27
27
|
```
|
|
28
28
|
tests/
|
|
29
|
-
helpers
|
|
29
|
+
helpers/ # shared setup, factories, harnesses (see below)
|
|
30
30
|
services/
|
|
31
31
|
foo.test.ts # Unit tests (mocked deps)
|
|
32
32
|
foo.integration.test.ts # Integration tests (real services)
|
|
33
33
|
controllers/
|
|
34
|
-
|
|
34
|
+
itemsAPI.test.ts # HTTP route tests
|
|
35
35
|
middleware/
|
|
36
36
|
utils/
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
- `.test.ts` for Jest auto-discovery
|
|
39
|
+
- `.test.ts` for Jest auto-discovery. Keep the mocked tests and the real-service tests for the same subject in **separate files**: `jest.mock` is hoisted above the imports and applies to every test in the module, so one file cannot both stub a dependency and exercise the real one. `.integration.test.ts` is the conventional name for the half that uses the real services.
|
|
40
40
|
- Group by domain: `services/`, `controllers/`, `middleware/`
|
|
41
41
|
|
|
42
|
-
## Test Helpers
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
## Test Helpers
|
|
43
|
+
|
|
44
|
+
Shared test setup belongs in one place under `tests/`, not re-derived in each spec file: booting the app, creating the entities a test needs and tearing them down again, and harnesses that make an awkward dependency — a broker, a queue, a rendered template — drivable from a test.
|
|
45
|
+
|
|
46
|
+
**Read what the project already has before you write one.** These helpers are not scaffolded and cannot be, because they follow whichever capabilities and domain the project has. Every project grows its own set, and the shape varies from one shared module to a directory of purpose-built ones. Reuse what fits and extend it, rather than starting a private copy inside your test file. A helper used by exactly one test is not a helper — leave it in the test.
|
|
47
|
+
|
|
48
|
+
When you do add one, keep two properties. A factory returns a real, persisted entity rather than a hand-built object literal, so the test exercises the same shape production does. And whatever creates data owns its teardown, so one failed run does not poison the next.
|
|
46
49
|
|
|
47
50
|
## Integration Test Data Isolation
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
|
|
52
|
+
Integration tests share their backing services with every other integration test, and with whatever the last run left behind. Three habits keep them from interfering with each other:
|
|
53
|
+
|
|
54
|
+
- **Namespace whatever you create.** A per-run prefix (`_test/${Date.now()}`, a UUID) keeps parallel runs and repeat runs from colliding on unique constraints.
|
|
55
|
+
- **Track created records and remove them in `afterAll`**, each cleanup wrapped in its own `try/catch` so one failure doesn't strand the rest.
|
|
56
|
+
- **Scope instead of deleting where deletes are expensive.** Some stores — append-only logs, analytics columnstores, anything with asynchronous merge semantics — make row deletion slow or eventually-consistent. Isolate those by writing under a test-specific tenant or key and leave the rows in place.
|
|
51
57
|
|
|
52
58
|
## HTTP Route Tests
|
|
53
|
-
Test Express routes via **real HTTP requests** (`fetch()`) against a running local server. This exercises the full middleware chain (auth →
|
|
59
|
+
Test Express routes via **real HTTP requests** (`fetch()`) against a running local server. This exercises the full middleware chain (auth → controller → service → DB → response).
|
|
54
60
|
|
|
55
61
|
Why real HTTP over supertest: supertest skips TCP/listen and can mask middleware ordering or config bugs. Auth middleware is a critical path worth testing through, not around.
|
|
56
62
|
|
|
57
63
|
**Setup:**
|
|
58
64
|
```typescript
|
|
59
65
|
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
|
|
60
|
-
const TEST_API_KEY = process.env.TEST_API_KEY; //
|
|
66
|
+
const TEST_API_KEY = process.env.TEST_API_KEY; // however this project issues API keys
|
|
61
67
|
const authHeaders = { 'Authorization': `Bearer ${TEST_API_KEY}`, 'Content-Type': 'application/json' };
|
|
62
68
|
```
|
|
63
69
|
|
|
64
|
-
**What to cover:** auth rejection (401/403),
|
|
70
|
+
**What to cover:** auth rejection (401/403), request validation (400), the happy-path response shape, and the authorization boundary — that one account cannot reach another's data. If the project gates routes on anything further (feature flags, roles, plan tiers), cover the closed gate too, and assert the status code it actually returns rather than the one that seems right.
|
|
71
|
+
|
|
72
|
+
**Prereqs:** a local server running against a reachable database, a durable test account, and whatever credential the auth middleware expects (`TEST_API_KEY` by convention).
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
## Ad-Hoc Testing Tools
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
Jest is what runs in CI and before a commit. Alongside it, projects grow small CLIs for the things a test suite is the wrong shape for: generating fixture data, poking a live local server, checking what actually landed in the database.
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|---|---|
|
|
72
|
-
| `npm run tester -- <cmd>` | Exploratory smoke tests, fixture data generation (`crawl`, `access-logs-generate`, `s3-raw-smoke`, `user-agent`, etc.) |
|
|
73
|
-
| `npm run admin -- <cmd>` | Entity/data operations (accounts, agents, credentials, CH export/replicate) |
|
|
78
|
+
Boop scaffolds one — `npm run db:query` (database capability) — for reading the local database without opening Prisma Studio. Anything beyond that is project-grown; `package.json` scripts and `src/scripts/` are where to look for what yours has, and `--help` on each is faster than reading its source.
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
These are exploratory aids. They are not part of the automated suite, and none of them substitutes for a Jest test that can go red on its own.
|
|
76
81
|
|
|
77
82
|
## References
|
|
78
83
|
|
|
79
|
-
- [
|
|
80
|
-
- [
|
|
81
|
-
-
|
|
82
|
-
-
|
|
84
|
+
- [`boop-testing-testing`](../boop-testing-testing/SKILL.md) — testing philosophy and the generic approach
|
|
85
|
+
- [`boop-testing-tdd-light`](../boop-testing-tdd-light/SKILL.md) — the red/green loop for a single change
|
|
86
|
+
- The project's own shared test setup under `tests/` — read it before adding another helper
|
|
87
|
+
- `jest.config.js` at the project root — test discovery, transform, and match config
|