Skip to content

Commit b7c8436

Browse files
PAMulliganclaude
andauthored
feat(tdd-from-schema): generate response schema snapshot tests (#98)
Add snapshot test patterns to the tdd-from-schema skill so generated test suites capture response body shape, headers, and error envelopes. This catches added/removed fields and renamed keys that status-code assertions miss. - Add normalizeForSnapshot / responseShape / snapshotHeaders helpers that strip UUIDs and ISO timestamps so snapshots stay stable - New Step 7 emits a *.snapshot.test.ts file per resource using toMatchInlineSnapshot for contract-critical bits (key lists, error codes, headers) and toMatchSnapshot for full normalized bodies - Document vitest -u workflow for intentional contract changes - Update Output table to list snapshot test files and __snapshots__/ Closes #47 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 57ad2a0 commit b7c8436

1 file changed

Lines changed: 254 additions & 2 deletions

File tree

.claude/skills/tdd-from-schema/SKILL.md

Lines changed: 254 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,48 @@ export function expectNotFoundError(body: any) {
252252
expect(body).toHaveProperty('error');
253253
expect(body.error).toHaveProperty('code', 'NOT_FOUND');
254254
}
255+
256+
// Snapshot normalizers -- strip volatile values so snapshots stay stable
257+
// across runs without losing the ability to detect schema drift.
258+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
259+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
260+
261+
export function normalizeForSnapshot(value: unknown): unknown {
262+
if (value === null || value === undefined) return value;
263+
if (Array.isArray(value)) return value.map(normalizeForSnapshot);
264+
if (typeof value === 'string') {
265+
if (UUID_RE.test(value)) return '<uuid>';
266+
if (ISO_DATE_RE.test(value)) return '<iso-date>';
267+
return value;
268+
}
269+
if (typeof value === 'object') {
270+
const out: Record<string, unknown> = {};
271+
for (const key of Object.keys(value as object).sort()) {
272+
out[key] = normalizeForSnapshot((value as Record<string, unknown>)[key]);
273+
}
274+
return out;
275+
}
276+
return value;
277+
}
278+
279+
// Snapshot just the *shape* of a response (sorted top-level keys) -- catches
280+
// added/removed fields without coupling to values that legitimately change.
281+
export function responseShape(body: unknown): string[] {
282+
if (body === null || typeof body !== 'object') return [];
283+
return Object.keys(body as object).sort();
284+
}
285+
286+
// Pick the headers we actually care about for contract stability. Vary the
287+
// list per route group if you add custom headers (e.g. X-RateLimit-*).
288+
export function snapshotHeaders(
289+
res: Response,
290+
extra: string[] = [],
291+
): Record<string, string | null> {
292+
const keys = ['content-type', 'cache-control', 'vary', ...extra];
293+
const out: Record<string, string | null> = {};
294+
for (const key of keys) out[key] = res.headers.get(key);
295+
return out;
296+
}
255297
```
256298

257299
### Step 4 -- Create the Hono Test App Helper
@@ -882,7 +924,215 @@ describe('Orders API', () => {
882924
});
883925
```
884926

885-
### Step 7 -- Confirm RED Phase (Hard Gate)
927+
### Step 7 -- Add Response Schema Snapshot Tests
928+
929+
Status codes and `expectPaginatedResponse` catch broad shape changes, but they
930+
do **not** catch "the `email` field was renamed to `emailAddress`" or "the
931+
`address` object lost its `country` key." Those are breaking changes for every
932+
client. Add a focused snapshot suite per resource that captures:
933+
934+
1. **Response body keys** -- with `toMatchInlineSnapshot()` for visibility in
935+
the test file itself. Derive the expected keys from the OpenAPI spec /
936+
Drizzle schema when generating the test so the inline value is correct from
937+
day one (the test then fails in RED until the handler matches, and acts as
938+
a regression check in GREEN).
939+
2. **Full normalized response body** -- with `toMatchSnapshot()` to a sibling
940+
`__snapshots__` directory. Pass the body through `normalizeForSnapshot()`
941+
so UUIDs and timestamps collapse to placeholders.
942+
3. **Response headers** -- `Content-Type`, `Cache-Control`, and any custom
943+
headers (rate-limit, deprecation, request-id) the API contract promises.
944+
4. **Error response shapes** -- the validation, not-found, unauthorized, and
945+
forbidden envelopes. Consumers branch on these too.
946+
947+
**Example: Books snapshot file**
948+
949+
```typescript
950+
// api/tests/routes/books.snapshot.test.ts
951+
import { describe, it, expect, beforeEach } from 'vitest';
952+
import { getTestApp } from '../helpers/app';
953+
import {
954+
createTestAdmin,
955+
createTestBook,
956+
generateTestToken,
957+
authHeader,
958+
normalizeForSnapshot,
959+
responseShape,
960+
snapshotHeaders,
961+
} from '../helpers';
962+
963+
describe('Books API -- response schema snapshots', () => {
964+
let app: ReturnType<typeof getTestApp>;
965+
966+
beforeEach(() => {
967+
app = getTestApp();
968+
});
969+
970+
it('GET /books/:id response body keys match the contract', async () => {
971+
const book = await createTestBook();
972+
973+
const res = await app.request(`/books/${book.id}`);
974+
const body = await res.json();
975+
976+
expect(responseShape(body)).toMatchInlineSnapshot(`
977+
[
978+
"author",
979+
"createdAt",
980+
"id",
981+
"isbn",
982+
"price",
983+
"stock",
984+
"title",
985+
"updatedAt",
986+
]
987+
`);
988+
});
989+
990+
it('GET /books/:id full response matches snapshot', async () => {
991+
const book = await createTestBook({
992+
title: 'Snapshot Book',
993+
author: 'Snapshot Author',
994+
isbn: '9780000000099',
995+
price: '19.99',
996+
stock: 7,
997+
});
998+
999+
const res = await app.request(`/books/${book.id}`);
1000+
const body = await res.json();
1001+
1002+
expect(normalizeForSnapshot(body)).toMatchSnapshot();
1003+
});
1004+
1005+
it('GET /books response headers match contract', async () => {
1006+
const res = await app.request('/books');
1007+
1008+
expect(snapshotHeaders(res)).toMatchInlineSnapshot(`
1009+
{
1010+
"cache-control": null,
1011+
"content-type": "application/json; charset=UTF-8",
1012+
"vary": null,
1013+
}
1014+
`);
1015+
});
1016+
1017+
it('GET /books paginated envelope matches snapshot', async () => {
1018+
await createTestBook({ title: 'A', isbn: '9780000001001' });
1019+
await createTestBook({ title: 'B', isbn: '9780000001002' });
1020+
1021+
const res = await app.request('/books?page=1&limit=10');
1022+
const body = await res.json();
1023+
1024+
expect(responseShape(body)).toMatchInlineSnapshot(`
1025+
[
1026+
"data",
1027+
"meta",
1028+
]
1029+
`);
1030+
expect(responseShape(body.meta)).toMatchInlineSnapshot(`
1031+
[
1032+
"limit",
1033+
"page",
1034+
"total",
1035+
"totalPages",
1036+
]
1037+
`);
1038+
});
1039+
1040+
// --- Error shapes ---
1041+
1042+
it('400 validation error shape', async () => {
1043+
const admin = await createTestAdmin();
1044+
const token = await generateTestToken(admin.id, 'admin');
1045+
1046+
const res = await app.request('/books', {
1047+
method: 'POST',
1048+
headers: { 'Content-Type': 'application/json', ...authHeader(token) },
1049+
body: JSON.stringify({ title: 'Only Title' }),
1050+
});
1051+
const body = await res.json();
1052+
1053+
expect(res.status).toBe(400);
1054+
expect(responseShape(body.error)).toMatchInlineSnapshot(`
1055+
[
1056+
"code",
1057+
"details",
1058+
"message",
1059+
]
1060+
`);
1061+
expect(body.error.code).toMatchInlineSnapshot('"VALIDATION_ERROR"');
1062+
});
1063+
1064+
it('404 not-found error shape', async () => {
1065+
const res = await app.request('/books/00000000-0000-0000-0000-000000000000');
1066+
const body = await res.json();
1067+
1068+
expect(res.status).toBe(404);
1069+
expect(normalizeForSnapshot(body)).toMatchInlineSnapshot(`
1070+
{
1071+
"error": {
1072+
"code": "NOT_FOUND",
1073+
"message": "Book not found",
1074+
},
1075+
}
1076+
`);
1077+
});
1078+
1079+
it('401 unauthorized error shape', async () => {
1080+
const res = await app.request('/books', {
1081+
method: 'POST',
1082+
headers: { 'Content-Type': 'application/json' },
1083+
body: JSON.stringify({}),
1084+
});
1085+
const body = await res.json();
1086+
1087+
expect(res.status).toBe(401);
1088+
expect(responseShape(body.error)).toMatchInlineSnapshot(`
1089+
[
1090+
"code",
1091+
"message",
1092+
]
1093+
`);
1094+
});
1095+
});
1096+
```
1097+
1098+
**Generation rules** (the route-generation step relies on these):
1099+
1100+
- One `*.snapshot.test.ts` file per resource, alongside the CRUD test file.
1101+
- Inline snapshots (`toMatchInlineSnapshot`) for: top-level key lists, error
1102+
envelope keys, error `code` literals, and the headers object. These are the
1103+
**contract-critical** fields -- breakage shows up in the test file itself in
1104+
code review.
1105+
- File snapshots (`toMatchSnapshot`) for: full normalized response bodies and
1106+
any deeply-nested shapes. Stored under `tests/routes/__snapshots__/`.
1107+
- Always pass response bodies through `normalizeForSnapshot()` before
1108+
`toMatchSnapshot()` so UUIDs and ISO timestamps do not cause spurious diffs.
1109+
1110+
**Updating snapshots when a change is intentional**
1111+
1112+
A failing snapshot test means one of two things:
1113+
1114+
1. **Regression** -- the handler stopped returning a field the contract
1115+
promised. Fix the handler.
1116+
2. **Intentional contract change** -- the OpenAPI spec was deliberately
1117+
updated. After verifying the change is correct *and* documented in the
1118+
spec, regenerate snapshots:
1119+
1120+
```bash
1121+
cd api && pnpm vitest run -u # update all snapshots
1122+
cd api && pnpm vitest run tests/routes/books -u # update one resource
1123+
```
1124+
1125+
Commit the snapshot diff (`__snapshots__/*.snap` files and any inline
1126+
snapshot changes) as part of the same PR that changes the response shape,
1127+
and call it out in the PR description so reviewers explicitly acknowledge
1128+
the consumer-visible change.
1129+
1130+
> Snapshot tests fail in RED for the same reason every other test in this
1131+
> phase fails -- there is no handler yet. File snapshots will be created on
1132+
> the first GREEN run; inline snapshots derived from the spec already encode
1133+
> the expected shape and will start passing as soon as the handler matches.
1134+
1135+
### Step 8 -- Confirm RED Phase (Hard Gate)
8861136

8871137
Run the tests and confirm they all fail:
8881138

@@ -928,9 +1178,11 @@ cd api && pnpm vitest run --reporter=json 2>&1 | node -e "
9281178
|---|---|---|
9291179
| Test config | `api/vitest.config.ts` | Vitest configuration |
9301180
| Test setup | `api/tests/setup.ts` | Database container and cleanup |
931-
| Test helpers | `api/tests/helpers/index.ts` | Factories, auth helpers, assertions |
1181+
| Test helpers | `api/tests/helpers/index.ts` | Factories, auth helpers, assertions, snapshot normalizers |
9321182
| App helper | `api/tests/helpers/app.ts` | Test app instantiation |
9331183
| Route tests | `api/tests/routes/*.test.ts` | One test file per resource |
1184+
| Schema snapshot tests | `api/tests/routes/*.snapshot.test.ts` | Response body keys, normalized bodies, headers, and error envelopes |
1185+
| Snapshot store | `api/tests/routes/__snapshots__/*.snap` | Generated on first GREEN run; committed to the repo as the contract record |
9341186

9351187
## Integration
9361188

0 commit comments

Comments
 (0)