@@ -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
8871137Run 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