@typokit/testing 0.1.4

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.
@@ -0,0 +1,356 @@
1
+ // @typokit/testing — Contract Test Generation Tests
2
+
3
+ import { describe, it, expect } from "@rstest/core";
4
+ import {
5
+ generateContractTests,
6
+ detectTestRunner,
7
+ } from "./contract-generator.js";
8
+ import type {
9
+ ContractTestRoute,
10
+ ContractTestOptions,
11
+ } from "./contract-generator.js";
12
+ import type { SchemaTypeMap } from "@typokit/types";
13
+
14
+ // ─── Sample schemas ──────────────────────────────────────────
15
+
16
+ const sampleSchemas: SchemaTypeMap = {
17
+ CreateUserInput: {
18
+ name: "CreateUserInput",
19
+ properties: {
20
+ email: {
21
+ type: "string",
22
+ optional: false,
23
+ jsdoc: { format: "email" },
24
+ },
25
+ displayName: {
26
+ type: "string",
27
+ optional: false,
28
+ },
29
+ role: {
30
+ type: '"admin" | "user" | "moderator"',
31
+ optional: true,
32
+ },
33
+ },
34
+ },
35
+ PublicUser: {
36
+ name: "PublicUser",
37
+ properties: {
38
+ id: { type: "string", optional: false },
39
+ email: { type: "string", optional: false },
40
+ displayName: { type: "string", optional: false },
41
+ },
42
+ },
43
+ UpdateUserInput: {
44
+ name: "UpdateUserInput",
45
+ properties: {
46
+ displayName: { type: "string", optional: true },
47
+ age: { type: "number", optional: true },
48
+ },
49
+ },
50
+ CreatePostInput: {
51
+ name: "CreatePostInput",
52
+ properties: {
53
+ title: { type: "string", optional: false },
54
+ body: { type: "string", optional: false },
55
+ published: { type: "boolean", optional: false },
56
+ },
57
+ },
58
+ };
59
+
60
+ const sampleRoutes: ContractTestRoute[] = [
61
+ {
62
+ method: "GET",
63
+ path: "/users",
64
+ handlerRef: "listUsers",
65
+ responseSchema: "PublicUser",
66
+ },
67
+ {
68
+ method: "POST",
69
+ path: "/users",
70
+ handlerRef: "createUser",
71
+ validators: { body: "CreateUserInput" },
72
+ responseSchema: "PublicUser",
73
+ },
74
+ {
75
+ method: "PUT",
76
+ path: "/users/:id",
77
+ handlerRef: "updateUser",
78
+ validators: { body: "UpdateUserInput" },
79
+ },
80
+ {
81
+ method: "GET",
82
+ path: "/posts",
83
+ handlerRef: "listPosts",
84
+ },
85
+ {
86
+ method: "POST",
87
+ path: "/posts",
88
+ handlerRef: "createPost",
89
+ validators: { body: "CreatePostInput" },
90
+ expectedStatus: 201,
91
+ },
92
+ ];
93
+
94
+ function makeOptions(
95
+ overrides?: Partial<ContractTestOptions>,
96
+ ): ContractTestOptions {
97
+ return {
98
+ runner: "vitest",
99
+ appImport: "../src/app",
100
+ routes: sampleRoutes,
101
+ schemas: sampleSchemas,
102
+ ...overrides,
103
+ };
104
+ }
105
+
106
+ // ─── Tests ───────────────────────────────────────────────────
107
+
108
+ describe("generateContractTests", () => {
109
+ it("generates files grouped by path prefix", () => {
110
+ const outputs = generateContractTests(makeOptions());
111
+
112
+ expect(outputs.length).toBe(2);
113
+
114
+ const filePaths = outputs.map((o) => o.filePath).sort();
115
+ expect(filePaths).toEqual([
116
+ "__generated__/posts.contract.test.ts",
117
+ "__generated__/users.contract.test.ts",
118
+ ]);
119
+ });
120
+
121
+ it("includes DO NOT EDIT header in all files", () => {
122
+ const outputs = generateContractTests(makeOptions());
123
+
124
+ for (const output of outputs) {
125
+ expect(output.content.startsWith("// DO NOT EDIT")).toBe(true);
126
+ expect(output.content).toContain("regenerated on schema change");
127
+ }
128
+ });
129
+
130
+ it("uses vitest imports for vitest runner", () => {
131
+ const outputs = generateContractTests(makeOptions({ runner: "vitest" }));
132
+ for (const output of outputs) {
133
+ expect(output.content).toContain('from "vitest"');
134
+ }
135
+ });
136
+
137
+ it("uses jest imports for jest runner", () => {
138
+ const outputs = generateContractTests(makeOptions({ runner: "jest" }));
139
+ for (const output of outputs) {
140
+ expect(output.content).toContain('from "@jest/globals"');
141
+ }
142
+ });
143
+
144
+ it("uses rstest imports for rstest runner", () => {
145
+ const outputs = generateContractTests(makeOptions({ runner: "rstest" }));
146
+ for (const output of outputs) {
147
+ expect(output.content).toContain('from "@rstest/core"');
148
+ }
149
+ });
150
+
151
+ it("imports createTestClient from @typokit/testing", () => {
152
+ const outputs = generateContractTests(makeOptions());
153
+
154
+ for (const output of outputs) {
155
+ expect(output.content).toContain(
156
+ 'import { createTestClient } from "@typokit/testing"',
157
+ );
158
+ }
159
+ });
160
+
161
+ it("imports app from the configured appImport path", () => {
162
+ const outputs = generateContractTests(
163
+ makeOptions({ appImport: "../../app/index" }),
164
+ );
165
+
166
+ for (const output of outputs) {
167
+ expect(output.content).toContain('import { app } from "../../app/index"');
168
+ }
169
+ });
170
+
171
+ it("generates valid input test for POST route with body schema", () => {
172
+ const outputs = generateContractTests(makeOptions());
173
+ const usersFile = outputs.find((o) => o.filePath.includes("users"))!;
174
+
175
+ expect(usersFile.content).toContain('describe("POST /users"');
176
+ expect(usersFile.content).toContain('it("accepts valid CreateUserInput"');
177
+ expect(usersFile.content).toContain("client.post");
178
+ expect(usersFile.content).toContain("email:");
179
+ expect(usersFile.content).toContain("test@example.com");
180
+ expect(usersFile.content).toContain("expect(res.status).toBe(200)");
181
+ });
182
+
183
+ it("generates toMatchSchema assertion when responseSchema is set", () => {
184
+ const outputs = generateContractTests(makeOptions());
185
+ const usersFile = outputs.find((o) => o.filePath.includes("users"))!;
186
+
187
+ expect(usersFile.content).toContain(
188
+ 'import { toMatchSchema } from "@typokit/testing"',
189
+ );
190
+ expect(usersFile.content).toContain('toMatchSchema("PublicUser")');
191
+ });
192
+
193
+ it("generates missing required fields tests", () => {
194
+ const outputs = generateContractTests(makeOptions());
195
+ const usersFile = outputs.find((o) => o.filePath.includes("users"))!;
196
+
197
+ expect(usersFile.content).toContain('it("rejects missing required fields"');
198
+ expect(usersFile.content).toContain("body: {}");
199
+ expect(usersFile.content).toContain("expect(res.status).toBe(400)");
200
+ });
201
+
202
+ it("generates per-field missing tests for required fields", () => {
203
+ const outputs = generateContractTests(makeOptions());
204
+ const usersFile = outputs.find((o) => o.filePath.includes("users"))!;
205
+
206
+ // CreateUserInput has required: email, displayName
207
+ expect(usersFile.content).toContain("it(\"rejects missing 'email' field\"");
208
+ expect(usersFile.content).toContain(
209
+ "it(\"rejects missing 'displayName' field\"",
210
+ );
211
+ });
212
+
213
+ it("generates invalid format tests for fields with format constraints", () => {
214
+ const outputs = generateContractTests(makeOptions());
215
+ const usersFile = outputs.find((o) => o.filePath.includes("users"))!;
216
+
217
+ expect(usersFile.content).toContain('it("rejects invalid email format"');
218
+ expect(usersFile.content).toContain("not-an-email");
219
+ });
220
+
221
+ it("generates simple response test for GET routes without body", () => {
222
+ const outputs = generateContractTests(makeOptions());
223
+ const usersFile = outputs.find((o) => o.filePath.includes("users"))!;
224
+
225
+ expect(usersFile.content).toContain('describe("GET /users"');
226
+ expect(usersFile.content).toContain('it("responds with 200"');
227
+ expect(usersFile.content).toContain("client.get");
228
+ });
229
+
230
+ it("respects custom expectedStatus", () => {
231
+ const outputs = generateContractTests(makeOptions());
232
+ const postsFile = outputs.find((o) => o.filePath.includes("posts"))!;
233
+
234
+ expect(postsFile.content).toContain("expect(res.status).toBe(201)");
235
+ });
236
+
237
+ it("generates invalid type tests for number and boolean fields", () => {
238
+ const outputs = generateContractTests(makeOptions());
239
+ const postsFile = outputs.find((o) => o.filePath.includes("posts"))!;
240
+
241
+ // CreatePostInput has published: boolean
242
+ expect(postsFile.content).toContain(
243
+ 'it("rejects invalid published format"',
244
+ );
245
+ });
246
+
247
+ it("generates beforeAll/afterAll for client lifecycle", () => {
248
+ const outputs = generateContractTests(makeOptions());
249
+
250
+ for (const output of outputs) {
251
+ expect(output.content).toContain("beforeAll(async () => {");
252
+ expect(output.content).toContain("client = await createTestClient(app);");
253
+ expect(output.content).toContain("afterAll(async () => {");
254
+ expect(output.content).toContain("await client.close();");
255
+ }
256
+ });
257
+
258
+ it("is idempotent — same input produces same output", () => {
259
+ const opts = makeOptions();
260
+ const first = generateContractTests(opts);
261
+ const second = generateContractTests(opts);
262
+
263
+ expect(first.length).toBe(second.length);
264
+ for (let i = 0; i < first.length; i++) {
265
+ expect(first[i].filePath).toBe(second[i].filePath);
266
+ expect(first[i].content).toBe(second[i].content);
267
+ }
268
+ });
269
+
270
+ it("handles routes with no validators gracefully", () => {
271
+ const routes: ContractTestRoute[] = [
272
+ {
273
+ method: "GET",
274
+ path: "/health",
275
+ handlerRef: "healthCheck",
276
+ },
277
+ ];
278
+
279
+ const outputs = generateContractTests(makeOptions({ routes }));
280
+
281
+ expect(outputs.length).toBe(1);
282
+ expect(outputs[0].filePath).toBe("__generated__/health.contract.test.ts");
283
+ expect(outputs[0].content).toContain('describe("GET /health"');
284
+ expect(outputs[0].content).toContain('it("responds with 200"');
285
+ // No missing fields or invalid format tests
286
+ expect(outputs[0].content).not.toContain("rejects missing");
287
+ expect(outputs[0].content).not.toContain("rejects invalid");
288
+ });
289
+
290
+ it("returns empty array for empty routes", () => {
291
+ const outputs = generateContractTests(makeOptions({ routes: [] }));
292
+ expect(outputs.length).toBe(0);
293
+ });
294
+
295
+ it("does not import toMatchSchema when no route has responseSchema", () => {
296
+ const routes: ContractTestRoute[] = [
297
+ {
298
+ method: "GET",
299
+ path: "/health",
300
+ handlerRef: "healthCheck",
301
+ },
302
+ ];
303
+
304
+ const outputs = generateContractTests(makeOptions({ routes }));
305
+
306
+ expect(outputs[0].content).not.toContain("toMatchSchema");
307
+ });
308
+ });
309
+
310
+ describe("detectTestRunner", () => {
311
+ it("detects rstest from devDependencies", () => {
312
+ expect(detectTestRunner({ devDependencies: { rstest: "^0.0.1" } })).toBe(
313
+ "rstest",
314
+ );
315
+ });
316
+
317
+ it("detects vitest from devDependencies", () => {
318
+ expect(detectTestRunner({ devDependencies: { vitest: "^1.0.0" } })).toBe(
319
+ "vitest",
320
+ );
321
+ });
322
+
323
+ it("detects jest from devDependencies", () => {
324
+ expect(detectTestRunner({ devDependencies: { jest: "^29.0.0" } })).toBe(
325
+ "jest",
326
+ );
327
+ });
328
+
329
+ it("detects jest from @jest/globals", () => {
330
+ expect(
331
+ detectTestRunner({
332
+ devDependencies: { "@jest/globals": "^29.0.0" },
333
+ }),
334
+ ).toBe("jest");
335
+ });
336
+
337
+ it("detects runner from test script", () => {
338
+ expect(
339
+ detectTestRunner({
340
+ scripts: { test: "rstest run --passWithNoTests" },
341
+ }),
342
+ ).toBe("rstest");
343
+ });
344
+
345
+ it("defaults to vitest when no runner detected", () => {
346
+ expect(detectTestRunner({})).toBe("vitest");
347
+ });
348
+
349
+ it("prefers rstest over vitest when both present", () => {
350
+ expect(
351
+ detectTestRunner({
352
+ devDependencies: { rstest: "^0.0.1", vitest: "^1.0.0" },
353
+ }),
354
+ ).toBe("rstest");
355
+ });
356
+ });