@vertz/testing 0.1.0 → 0.2.0

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/README.md ADDED
@@ -0,0 +1,739 @@
1
+ # @vertz/testing
2
+
3
+ Test utilities for Vertz applications. Write fast, isolated tests for your routes, services, and business logic.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Node.js** 18+ or **Bun** 1.0+
8
+ - **TypeScript** 5.0+
9
+ - **Dependencies:** `@vertz/core`
10
+ - **Test Framework:** Vitest (recommended) or any other test framework
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install --save-dev @vertz/testing
16
+ ```
17
+
18
+ This package provides test helpers that work seamlessly with Vitest (recommended) or any other test framework.
19
+
20
+ ## Quick Start
21
+
22
+ ### Testing a Route
23
+
24
+ ```ts
25
+ import { createTestApp } from '@vertz/testing';
26
+ import { UserModule } from './modules/users/user.module';
27
+ import { describe, expect, it } from 'vitest';
28
+
29
+ describe('User routes', () => {
30
+ it('returns list of users', async () => {
31
+ const app = createTestApp().register(UserModule);
32
+
33
+ const res = await app.get('/users');
34
+
35
+ expect(res.ok).toBe(true);
36
+ expect(res.body).toEqual({
37
+ users: expect.arrayContaining([
38
+ expect.objectContaining({ name: expect.any(String) }),
39
+ ]),
40
+ });
41
+ });
42
+
43
+ it('creates a new user', async () => {
44
+ const app = createTestApp().register(UserModule);
45
+
46
+ const res = await app.post('/users', {
47
+ body: { name: 'Jane Doe', email: 'jane@example.com' },
48
+ });
49
+
50
+ expect(res.status).toBe(201);
51
+ expect(res.body).toMatchObject({
52
+ id: expect.any(String),
53
+ name: 'Jane Doe',
54
+ });
55
+ });
56
+ });
57
+ ```
58
+
59
+ ### Testing a Service
60
+
61
+ ```ts
62
+ import { createTestService } from '@vertz/testing';
63
+ import { UserService, DatabaseService } from './services';
64
+ import { describe, expect, it } from 'vitest';
65
+
66
+ describe('UserService', () => {
67
+ it('finds user by ID', async () => {
68
+ const userService = await createTestService(UserService)
69
+ .mock(DatabaseService, {
70
+ query: () => Promise.resolve([{ id: '1', name: 'Jane' }]),
71
+ });
72
+
73
+ const user = await userService.findById('1');
74
+
75
+ expect(user).toEqual({ id: '1', name: 'Jane' });
76
+ });
77
+ });
78
+ ```
79
+
80
+ ## API Reference
81
+
82
+ ### `createTestApp()`
83
+
84
+ Creates a test application builder for integration testing routes and modules.
85
+
86
+ ```ts
87
+ import { createTestApp } from '@vertz/testing';
88
+
89
+ const app = createTestApp();
90
+ ```
91
+
92
+ Returns a `TestApp` instance with the following methods:
93
+
94
+ #### `app.register(module, options?)`
95
+
96
+ Register a module with optional configuration.
97
+
98
+ ```ts
99
+ app.register(UserModule);
100
+ app.register(UserModule, { apiKey: 'test-key' });
101
+ ```
102
+
103
+ **Parameters:**
104
+ - `module` — A Vertz module created with `createModule()`
105
+ - `options` — Optional configuration passed to the module
106
+
107
+ **Returns:** `TestApp` (chainable)
108
+
109
+ #### `app.mock(service, implementation)`
110
+
111
+ Mock a service at the application level (applies to all requests).
112
+
113
+ ```ts
114
+ app.mock(DatabaseService, {
115
+ query: () => Promise.resolve([]),
116
+ });
117
+ ```
118
+
119
+ **Parameters:**
120
+ - `service` — A service definition created with `moduleDef.service()`
121
+ - `implementation` — Partial implementation of the service methods
122
+
123
+ **Returns:** `TestApp` (chainable)
124
+
125
+ **Note:** You only need to implement the methods your tests use. Unimplemented methods will throw if called.
126
+
127
+ #### `app.mockMiddleware(middleware, result)`
128
+
129
+ Mock a middleware at the application level.
130
+
131
+ ```ts
132
+ app.mockMiddleware(AuthMiddleware, { user: { id: '1' } });
133
+ ```
134
+
135
+ **Parameters:**
136
+ - `middleware` — A middleware definition created with `createMiddleware()`
137
+ - `result` — The value the middleware should provide
138
+
139
+ **Returns:** `TestApp` (chainable)
140
+
141
+ #### `app.env(vars)`
142
+
143
+ Set environment variables for the test application.
144
+
145
+ ```ts
146
+ app.env({ DATABASE_URL: 'test-db', API_KEY: 'test-key' });
147
+ ```
148
+
149
+ **Parameters:**
150
+ - `vars` — Object with environment variable key-value pairs
151
+
152
+ **Returns:** `TestApp` (chainable)
153
+
154
+ #### HTTP Methods: `app.get()`, `app.post()`, `app.put()`, `app.patch()`, `app.delete()`, `app.head()`
155
+
156
+ Make HTTP requests to your application.
157
+
158
+ ```ts
159
+ const res = await app.get('/users');
160
+ const res = await app.post('/users', { body: { name: 'Jane' } });
161
+ const res = await app.put('/users/1', {
162
+ body: { name: 'Jane Doe' },
163
+ headers: { 'Authorization': 'Bearer token' },
164
+ });
165
+ ```
166
+
167
+ **Parameters:**
168
+ - `path` — Request path (e.g., `/users`, `/users/123`)
169
+ - `options` — Optional request options:
170
+ - `body` — Request body (automatically serialized as JSON)
171
+ - `headers` — Request headers
172
+
173
+ **Returns:** `TestRequestBuilder` (awaitable, see below)
174
+
175
+ ### `TestRequestBuilder`
176
+
177
+ Returned by HTTP method calls. It's **awaitable** and also allows per-request mocking.
178
+
179
+ #### Await the Request
180
+
181
+ ```ts
182
+ const res = await app.get('/users');
183
+ ```
184
+
185
+ Returns a `TestResponse`:
186
+
187
+ ```ts
188
+ interface TestResponse {
189
+ status: number; // HTTP status code
190
+ body: unknown; // Parsed response body (JSON)
191
+ headers: Record<string, string>; // Response headers
192
+ ok: boolean; // true if status 2xx
193
+ }
194
+ ```
195
+
196
+ #### Per-Request Mocking
197
+
198
+ Override mocks for a single request:
199
+
200
+ ```ts
201
+ const res = await app.get('/users')
202
+ .mock(DatabaseService, {
203
+ query: () => Promise.resolve([{ id: '1', name: 'Mock User' }]),
204
+ })
205
+ .mockMiddleware(AuthMiddleware, { user: { id: 'test-user' } });
206
+ ```
207
+
208
+ **Methods:**
209
+ - `mock(service, implementation)` — Mock a service for this request only
210
+ - `mockMiddleware(middleware, result)` — Mock a middleware for this request only
211
+
212
+ **Returns:** `TestRequestBuilder` (chainable and awaitable)
213
+
214
+ ### `createTestService(service)`
215
+
216
+ Creates a test builder for isolated service testing.
217
+
218
+ ```ts
219
+ import { createTestService } from '@vertz/testing';
220
+
221
+ const serviceInstance = await createTestService(UserService);
222
+ ```
223
+
224
+ **Parameters:**
225
+ - `service` — A service definition created with `moduleDef.service()`
226
+
227
+ **Returns:** `TestServiceBuilder` (awaitable, see below)
228
+
229
+ ### `TestServiceBuilder`
230
+
231
+ Returned by `createTestService()`. It's **awaitable** and allows dependency mocking.
232
+
233
+ #### Await the Service
234
+
235
+ ```ts
236
+ const service = await createTestService(UserService);
237
+ ```
238
+
239
+ Returns the service methods as an object.
240
+
241
+ #### Mock Dependencies
242
+
243
+ If the service has injected dependencies, you must mock them:
244
+
245
+ ```ts
246
+ const service = await createTestService(UserService)
247
+ .mock(DatabaseService, { query: () => Promise.resolve([]) })
248
+ .mock(CacheService, { get: () => null, set: () => {} });
249
+ ```
250
+
251
+ **Method:**
252
+ - `mock(dependency, implementation)` — Mock an injected dependency
253
+
254
+ **Returns:** `TestServiceBuilder` (chainable and awaitable)
255
+
256
+ **Error:** If you await a service with unmocked dependencies, it will throw:
257
+
258
+ ```ts
259
+ // ❌ Throws: "Missing mock for injected dependency 'db'"
260
+ const service = await createTestService(UserService);
261
+
262
+ // ✅ Correct
263
+ const service = await createTestService(UserService)
264
+ .mock(DatabaseService, { query: () => [] });
265
+ ```
266
+
267
+ ### `DeepPartial<T>`
268
+
269
+ Type helper for creating partial mocks.
270
+
271
+ ```ts
272
+ import type { DeepPartial } from '@vertz/testing';
273
+
274
+ const mock: DeepPartial<ComplexService> = {
275
+ users: {
276
+ findById: () => Promise.resolve({ id: '1' }),
277
+ // Other methods optional
278
+ },
279
+ };
280
+ ```
281
+
282
+ Allows you to implement only the methods you need for your test, even for nested objects.
283
+
284
+ ## Testing Patterns
285
+
286
+ ### Testing with Authentication
287
+
288
+ Mock the auth middleware to simulate authenticated requests:
289
+
290
+ ```ts
291
+ it('returns user profile when authenticated', async () => {
292
+ const app = createTestApp()
293
+ .register(UserModule)
294
+ .mockMiddleware(AuthMiddleware, {
295
+ user: { id: 'user-123', role: 'admin' }
296
+ });
297
+
298
+ const res = await app.get('/users/me');
299
+
300
+ expect(res.ok).toBe(true);
301
+ expect(res.body).toMatchObject({ id: 'user-123' });
302
+ });
303
+ ```
304
+
305
+ Per-request authentication:
306
+
307
+ ```ts
308
+ it('requires authentication', async () => {
309
+ const app = createTestApp().register(UserModule);
310
+
311
+ // No auth
312
+ const unauthorized = await app.get('/users/me');
313
+ expect(unauthorized.status).toBe(401);
314
+
315
+ // With auth
316
+ const authorized = await app.get('/users/me')
317
+ .mockMiddleware(AuthMiddleware, { user: { id: '1' } });
318
+ expect(authorized.ok).toBe(true);
319
+ });
320
+ ```
321
+
322
+ ### Testing with Database
323
+
324
+ Mock database services to avoid hitting a real database:
325
+
326
+ ```ts
327
+ it('creates a user in the database', async () => {
328
+ const mockDb = {
329
+ insert: vi.fn().mockResolvedValue({ id: 'new-id' }),
330
+ query: vi.fn(),
331
+ };
332
+
333
+ const app = createTestApp()
334
+ .register(UserModule)
335
+ .mock(DatabaseService, mockDb);
336
+
337
+ await app.post('/users', { body: { name: 'Jane' } });
338
+
339
+ expect(mockDb.insert).toHaveBeenCalledWith(
340
+ expect.objectContaining({ name: 'Jane' })
341
+ );
342
+ });
343
+ ```
344
+
345
+ ### Testing Error Handling
346
+
347
+ ```ts
348
+ it('returns 404 for missing user', async () => {
349
+ const app = createTestApp()
350
+ .register(UserModule)
351
+ .mock(DatabaseService, {
352
+ findById: () => null,
353
+ });
354
+
355
+ const res = await app.get('/users/999');
356
+
357
+ expect(res.status).toBe(404);
358
+ expect(res.body).toMatchObject({
359
+ error: 'NotFound',
360
+ message: expect.any(String),
361
+ });
362
+ });
363
+
364
+ it('returns 400 for invalid input', async () => {
365
+ const app = createTestApp().register(UserModule);
366
+
367
+ const res = await app.post('/users', {
368
+ body: { email: 'not-an-email' }, // Invalid email
369
+ });
370
+
371
+ expect(res.status).toBe(400);
372
+ });
373
+ ```
374
+
375
+ ### Testing Services with Dependencies
376
+
377
+ ```ts
378
+ it('user service finds user by email', async () => {
379
+ const userService = await createTestService(UserService)
380
+ .mock(DatabaseService, {
381
+ query: (sql: string) => {
382
+ if (sql.includes('email')) {
383
+ return Promise.resolve([{ id: '1', email: 'jane@example.com' }]);
384
+ }
385
+ return Promise.resolve([]);
386
+ },
387
+ });
388
+
389
+ const user = await userService.findByEmail('jane@example.com');
390
+
391
+ expect(user).toMatchObject({ id: '1', email: 'jane@example.com' });
392
+ });
393
+ ```
394
+
395
+ ### Testing with Query Parameters
396
+
397
+ ```ts
398
+ it('filters users by query params', async () => {
399
+ const app = createTestApp().register(UserModule);
400
+
401
+ const res = await app.get('/users?role=admin&active=true');
402
+
403
+ expect(res.ok).toBe(true);
404
+ expect(res.body.users).toEqual(
405
+ expect.arrayContaining([
406
+ expect.objectContaining({ role: 'admin', active: true }),
407
+ ])
408
+ );
409
+ });
410
+ ```
411
+
412
+ ### Testing Response Headers
413
+
414
+ ```ts
415
+ it('sets cache headers', async () => {
416
+ const app = createTestApp().register(UserModule);
417
+
418
+ const res = await app.get('/users');
419
+
420
+ expect(res.headers['cache-control']).toBe('public, max-age=3600');
421
+ });
422
+ ```
423
+
424
+ ### Testing with Request Headers
425
+
426
+ ```ts
427
+ it('accepts custom headers', async () => {
428
+ const app = createTestApp().register(ApiModule);
429
+
430
+ const res = await app.get('/data', {
431
+ headers: {
432
+ 'X-API-Key': 'test-key',
433
+ 'Accept-Language': 'en-US',
434
+ },
435
+ });
436
+
437
+ expect(res.ok).toBe(true);
438
+ });
439
+ ```
440
+
441
+ ### Testing Schema Validation
442
+
443
+ Vertz automatically validates request/response schemas. Test that validation works:
444
+
445
+ ```ts
446
+ it('validates request body against schema', async () => {
447
+ const app = createTestApp().register(UserModule);
448
+
449
+ const res = await app.post('/users', {
450
+ body: { name: '', email: 'invalid' }, // Invalid data
451
+ });
452
+
453
+ expect(res.status).toBe(400);
454
+ expect(res.body).toMatchObject({
455
+ error: 'BadRequest',
456
+ });
457
+ });
458
+ ```
459
+
460
+ ### Testing Route Parameters
461
+
462
+ ```ts
463
+ it('handles route parameters', async () => {
464
+ const app = createTestApp().register(UserModule);
465
+
466
+ const res = await app.get('/users/user-123');
467
+
468
+ expect(res.ok).toBe(true);
469
+ expect(res.body.id).toBe('user-123');
470
+ });
471
+ ```
472
+
473
+ ### Testing Multiple Modules
474
+
475
+ ```ts
476
+ it('integrates multiple modules', async () => {
477
+ const app = createTestApp()
478
+ .register(UserModule)
479
+ .register(AuthModule)
480
+ .register(PaymentModule);
481
+
482
+ // Test cross-module behavior
483
+ const loginRes = await app.post('/auth/login', {
484
+ body: { email: 'jane@example.com', password: 'secret' },
485
+ });
486
+
487
+ const token = loginRes.body.token;
488
+
489
+ const profileRes = await app.get('/users/me', {
490
+ headers: { Authorization: `Bearer ${token}` },
491
+ });
492
+
493
+ expect(profileRes.ok).toBe(true);
494
+ });
495
+ ```
496
+
497
+ ### Testing with Service State
498
+
499
+ If a service maintains state via `onInit`:
500
+
501
+ ```ts
502
+ it('initializes service with state', async () => {
503
+ const service = await createTestService(CounterService)
504
+ .mock(StorageService, {
505
+ load: () => Promise.resolve({ count: 5 }),
506
+ });
507
+
508
+ const count = await service.getCount();
509
+ expect(count).toBe(5);
510
+
511
+ await service.increment();
512
+ const newCount = await service.getCount();
513
+ expect(newCount).toBe(6);
514
+ });
515
+ ```
516
+
517
+ ### Testing Module Options
518
+
519
+ Pass module options to configure behavior:
520
+
521
+ ```ts
522
+ it('uses module options', async () => {
523
+ const app = createTestApp().register(UserModule, {
524
+ maxUsers: 100,
525
+ enableCache: false,
526
+ });
527
+
528
+ const res = await app.get('/users');
529
+ expect(res.ok).toBe(true);
530
+ });
531
+ ```
532
+
533
+ ## Integration with Vitest
534
+
535
+ This package is designed to work seamlessly with Vitest. Add to your `vitest.config.ts`:
536
+
537
+ ```ts
538
+ import { defineConfig } from 'vitest/config';
539
+
540
+ export default defineConfig({
541
+ test: {
542
+ globals: true,
543
+ environment: 'node',
544
+ },
545
+ });
546
+ ```
547
+
548
+ Then write tests using Vitest's API:
549
+
550
+ ```ts
551
+ import { createTestApp } from '@vertz/testing';
552
+ import { describe, expect, it, beforeEach, vi } from 'vitest';
553
+
554
+ describe('User routes', () => {
555
+ let app: ReturnType<typeof createTestApp>;
556
+
557
+ beforeEach(() => {
558
+ app = createTestApp().register(UserModule);
559
+ });
560
+
561
+ it('returns users', async () => {
562
+ const res = await app.get('/users');
563
+ expect(res.ok).toBe(true);
564
+ });
565
+ });
566
+ ```
567
+
568
+ ### Using Vitest Mocks
569
+
570
+ Combine with `vi.fn()` for advanced mocking:
571
+
572
+ ```ts
573
+ it('calls database with correct params', async () => {
574
+ const queryFn = vi.fn().mockResolvedValue([]);
575
+
576
+ const app = createTestApp()
577
+ .register(UserModule)
578
+ .mock(DatabaseService, { query: queryFn });
579
+
580
+ await app.get('/users?role=admin');
581
+
582
+ expect(queryFn).toHaveBeenCalledWith(
583
+ expect.stringContaining('role = $1'),
584
+ ['admin']
585
+ );
586
+ });
587
+ ```
588
+
589
+ ## TypeScript Support
590
+
591
+ All exports are fully typed with strict type inference:
592
+
593
+ ```ts
594
+ import type {
595
+ TestApp,
596
+ TestRequestBuilder,
597
+ TestResponse,
598
+ TestServiceBuilder,
599
+ DeepPartial,
600
+ } from '@vertz/testing';
601
+ ```
602
+
603
+ The test utilities preserve full type safety:
604
+
605
+ ```ts
606
+ // ✅ Type-safe service methods
607
+ const service = await createTestService(UserService);
608
+ const user = await service.findById('1'); // Typed as User
609
+
610
+ // ✅ Type-safe mocks
611
+ app.mock(DatabaseService, {
612
+ query: () => [], // Return type inferred
613
+ });
614
+
615
+ // ❌ TypeScript error: Property 'invalidMethod' does not exist
616
+ app.mock(DatabaseService, {
617
+ invalidMethod: () => {},
618
+ });
619
+ ```
620
+
621
+ ## Tips & Best Practices
622
+
623
+ ### 1. Use Per-Request Mocks for Varying Behavior
624
+
625
+ When you need different behavior per test case, use per-request mocks:
626
+
627
+ ```ts
628
+ it('handles different user states', async () => {
629
+ const app = createTestApp().register(UserModule);
630
+
631
+ // Active user
632
+ const activeRes = await app.get('/users/1')
633
+ .mock(DatabaseService, { findById: () => ({ id: '1', active: true }) });
634
+ expect(activeRes.body.active).toBe(true);
635
+
636
+ // Inactive user
637
+ const inactiveRes = await app.get('/users/1')
638
+ .mock(DatabaseService, { findById: () => ({ id: '1', active: false }) });
639
+ expect(inactiveRes.body.active).toBe(false);
640
+ });
641
+ ```
642
+
643
+ ### 2. Mock Only What You Need
644
+
645
+ You don't need to implement every method — only the ones your test uses:
646
+
647
+ ```ts
648
+ // ✅ Minimal mock
649
+ app.mock(DatabaseService, {
650
+ findById: () => ({ id: '1' }),
651
+ // Other methods omitted — they won't be called
652
+ });
653
+ ```
654
+
655
+ ### 3. Use `beforeEach` for Common Setup
656
+
657
+ ```ts
658
+ describe('User API', () => {
659
+ let app: ReturnType<typeof createTestApp>;
660
+
661
+ beforeEach(() => {
662
+ app = createTestApp()
663
+ .register(UserModule)
664
+ .mock(DatabaseService, { /* common mocks */ });
665
+ });
666
+
667
+ it('test 1', async () => { /* ... */ });
668
+ it('test 2', async () => { /* ... */ });
669
+ });
670
+ ```
671
+
672
+ ### 4. Test Real Error Cases
673
+
674
+ Mock errors to test error handling:
675
+
676
+ ```ts
677
+ it('handles database errors', async () => {
678
+ const app = createTestApp()
679
+ .register(UserModule)
680
+ .mock(DatabaseService, {
681
+ query: () => { throw new Error('Connection failed'); },
682
+ });
683
+
684
+ const res = await app.get('/users');
685
+
686
+ expect(res.status).toBe(500);
687
+ });
688
+ ```
689
+
690
+ ### 5. Test Response Schemas
691
+
692
+ If you define response schemas, Vertz validates them automatically:
693
+
694
+ ```ts
695
+ it('response matches schema', async () => {
696
+ const app = createTestApp().register(UserModule);
697
+
698
+ const res = await app.get('/users/1');
699
+
700
+ // If the handler returns data that doesn't match the schema,
701
+ // the test will throw a ResponseValidationError
702
+ expect(res.ok).toBe(true);
703
+ });
704
+ ```
705
+
706
+ ## Common Errors
707
+
708
+ ### Missing Mock for Dependency
709
+
710
+ ```
711
+ Error: Missing mock for injected dependency "db".
712
+ Call .mock(dbService, impl) before awaiting.
713
+ ```
714
+
715
+ **Solution:** Mock all injected dependencies:
716
+
717
+ ```ts
718
+ const service = await createTestService(UserService)
719
+ .mock(DatabaseService, { query: () => [] });
720
+ ```
721
+
722
+ ### Response Validation Error
723
+
724
+ ```
725
+ ResponseValidationError: Response validation failed: Invalid type
726
+ ```
727
+
728
+ **Cause:** The route handler returned data that doesn't match the response schema.
729
+
730
+ **Solution:** Fix the handler or the schema to match expected output.
731
+
732
+ ## Related Packages
733
+
734
+ - [@vertz/core](../core) — Core framework and module system
735
+ - [@vertz/schema](../schema) — Schema validation (used in request/response validation)
736
+
737
+ ## License
738
+
739
+ MIT
package/dist/index.d.ts CHANGED
@@ -1,26 +1,45 @@
1
- import { NamedMiddlewareDef, NamedModule, NamedServiceDef } from "@vertz/core";
1
+ import { NamedMiddlewareDef, NamedModule, NamedServiceDef } from "@vertz/server";
2
2
  type DeepPartial<T> = { [P in keyof T]? : T[P] extends object ? DeepPartial<T[P]> : T[P] };
3
- interface TestResponse {
4
- status: number;
3
+ /**
4
+ * Route map entry shape that each route in AppRouteMap must follow.
5
+ */
6
+ interface RouteMapEntry {
7
+ params: Record<string, string>;
8
+ query: Record<string, string>;
5
9
  body: unknown;
6
10
  headers: Record<string, string>;
11
+ response: unknown;
12
+ }
13
+ /**
14
+ * TestResponse with typed body based on route's response type.
15
+ */
16
+ interface TestResponse<TResponse = unknown> {
17
+ status: number;
18
+ body: TResponse;
19
+ headers: Record<string, string>;
7
20
  ok: boolean;
8
21
  }
9
- interface TestRequestBuilder extends PromiseLike<TestResponse> {
22
+ interface TestRequestBuilder<TResponse = unknown> extends PromiseLike<TestResponse<TResponse>> {
10
23
  mock<
11
24
  TDeps,
12
25
  TState,
13
26
  TMethods
14
- >(service: NamedServiceDef<TDeps, TState, TMethods>, impl: DeepPartial<TMethods>): TestRequestBuilder;
27
+ >(service: NamedServiceDef<TDeps, TState, TMethods>, impl: DeepPartial<TMethods>): TestRequestBuilder<TResponse>;
15
28
  mockMiddleware<
16
29
  TReq extends Record<string, unknown>,
17
30
  TProv extends Record<string, unknown>
18
- >(middleware: NamedMiddlewareDef<TReq, TProv>, result: TProv): TestRequestBuilder;
31
+ >(middleware: NamedMiddlewareDef<TReq, TProv>, result: TProv): TestRequestBuilder<TResponse>;
19
32
  }
20
- interface RequestOptions {
21
- body?: unknown;
33
+ /**
34
+ * Input options for a request, with typed body from the route map.
35
+ */
36
+ interface RequestOptions<TBody = unknown> {
37
+ body?: TBody;
22
38
  headers?: Record<string, string>;
23
39
  }
40
+ /**
41
+ * Untyped test app interface for backwards compatibility.
42
+ */
24
43
  interface TestApp {
25
44
  register(module: NamedModule, options?: Record<string, unknown>): TestApp;
26
45
  mock<
@@ -40,18 +59,86 @@ interface TestApp {
40
59
  delete(path: string, options?: RequestOptions): TestRequestBuilder;
41
60
  head(path: string, options?: RequestOptions): TestRequestBuilder;
42
61
  }
62
+ /**
63
+ * Typed test app interface with route map for type-safe requests.
64
+ * @template TRouteMap - Route map interface mapping route keys (e.g., 'GET /users') to their types.
65
+ */
66
+ interface TestAppWithRoutes<TRouteMap extends RouteMapEntry> {
67
+ register(module: NamedModule, options?: Record<string, unknown>): TestAppWithRoutes<TRouteMap>;
68
+ mock<
69
+ TDeps,
70
+ TState,
71
+ TMethods
72
+ >(service: NamedServiceDef<TDeps, TState, TMethods>, impl: DeepPartial<TMethods>): TestAppWithRoutes<TRouteMap>;
73
+ mockMiddleware<
74
+ TReq extends Record<string, unknown>,
75
+ TProv extends Record<string, unknown>
76
+ >(middleware: NamedMiddlewareDef<TReq, TProv>, result: TProv): TestAppWithRoutes<TRouteMap>;
77
+ env(vars: Record<string, unknown>): TestAppWithRoutes<TRouteMap>;
78
+ get<TKey extends `GET ${string}` & keyof TRouteMap>(path: string, options?: RequestOptions<TRouteMap[TKey] extends {
79
+ body: infer TBody;
80
+ } ? TBody : never>): TestRequestBuilder<TRouteMap[TKey] extends {
81
+ response: infer TResponse;
82
+ } ? TResponse : unknown>;
83
+ post<TKey extends `POST ${string}` & keyof TRouteMap>(path: string, options?: RequestOptions<TRouteMap[TKey] extends {
84
+ body: infer TBody;
85
+ } ? TBody : never>): TestRequestBuilder<TRouteMap[TKey] extends {
86
+ response: infer TResponse;
87
+ } ? TResponse : unknown>;
88
+ put<TKey extends `PUT ${string}` & keyof TRouteMap>(path: string, options?: RequestOptions<TRouteMap[TKey] extends {
89
+ body: infer TBody;
90
+ } ? TBody : never>): TestRequestBuilder<TRouteMap[TKey] extends {
91
+ response: infer TResponse;
92
+ } ? TResponse : unknown>;
93
+ patch<TKey extends `PATCH ${string}` & keyof TRouteMap>(path: string, options?: RequestOptions<TRouteMap[TKey] extends {
94
+ body: infer TBody;
95
+ } ? TBody : never>): TestRequestBuilder<TRouteMap[TKey] extends {
96
+ response: infer TResponse;
97
+ } ? TResponse : unknown>;
98
+ delete<TKey extends `DELETE ${string}` & keyof TRouteMap>(path: string, options?: RequestOptions<TRouteMap[TKey] extends {
99
+ body: infer TBody;
100
+ } ? TBody : never>): TestRequestBuilder<TRouteMap[TKey] extends {
101
+ response: infer TResponse;
102
+ } ? TResponse : unknown>;
103
+ head<TKey extends `HEAD ${string}` & keyof TRouteMap>(path: string, options?: RequestOptions<TRouteMap[TKey] extends {
104
+ body: infer TBody;
105
+ } ? TBody : never>): TestRequestBuilder<TRouteMap[TKey] extends {
106
+ response: infer TResponse;
107
+ } ? TResponse : unknown>;
108
+ }
109
+ /**
110
+ * Creates a test app for making HTTP requests against registered modules.
111
+ * Returns an untyped app for backwards compatibility.
112
+ * @returns A test app instance for registering modules and making requests.
113
+ */
43
114
  declare function createTestApp(): TestApp;
115
+ /**
116
+ * Creates a typed test app with route map for type-safe requests.
117
+ * @template TRouteMap - Route map interface mapping route keys to their types.
118
+ * @returns A typed test app instance.
119
+ */
120
+ declare function createTestApp<TRouteMap extends RouteMapEntry>(): TestAppWithRoutes<TRouteMap>;
44
121
  import { NamedServiceDef as NamedServiceDef2 } from "@vertz/core";
45
- interface TestServiceBuilder<TMethods> extends PromiseLike<TMethods> {
122
+ interface TestServiceBuilder<
123
+ TDeps,
124
+ TState,
125
+ TMethods,
126
+ TOptions extends Record<string, unknown> = Record<string, unknown>,
127
+ TEnv extends Record<string, unknown> = Record<string, unknown>
128
+ > extends PromiseLike<TMethods> {
46
129
  mock<
47
130
  TDep,
48
- TState,
131
+ TDepState,
49
132
  TMock
50
- >(service: NamedServiceDef2<TDep, TState, TMock>, impl: DeepPartial<TMock>): TestServiceBuilder<TMethods>;
133
+ >(service: NamedServiceDef2<TDep, TDepState, TMock>, impl: DeepPartial<TMock>): TestServiceBuilder<TDeps, TState, TMethods, TOptions, TEnv>;
134
+ options(opts: Partial<TOptions>): TestServiceBuilder<TDeps, TState, TMethods, TOptions, TEnv>;
135
+ env(env: Partial<TEnv>): TestServiceBuilder<TDeps, TState, TMethods, TOptions, TEnv>;
51
136
  }
52
137
  declare function createTestService<
53
138
  TDeps,
54
139
  TState,
55
- TMethods
56
- >(serviceDef: NamedServiceDef2<TDeps, TState, TMethods>): TestServiceBuilder<TMethods>;
57
- export { createTestService, createTestApp, TestServiceBuilder, TestResponse, TestRequestBuilder, TestApp, DeepPartial };
140
+ TMethods,
141
+ TOptions extends Record<string, unknown> = Record<string, unknown>,
142
+ TEnv extends Record<string, unknown> = Record<string, unknown>
143
+ >(serviceDef: NamedServiceDef2<TDeps, TState, TMethods, TOptions, TEnv>): TestServiceBuilder<TDeps, TState, TMethods, TOptions, TEnv>;
144
+ export { createTestService, createTestApp, TestServiceBuilder, TestResponse, TestRequestBuilder, TestApp, RouteMapEntry, RequestOptions, DeepPartial };
package/dist/index.js CHANGED
@@ -1,7 +1,4 @@
1
1
  // src/test-app.ts
2
- import {
3
- BadRequestException
4
- } from "@vertz/core";
5
2
  import {
6
3
  buildCtx,
7
4
  createErrorResponse,
@@ -11,13 +8,9 @@ import {
11
8
  runMiddlewareChain,
12
9
  Trie
13
10
  } from "@vertz/core/internals";
14
-
15
- class ResponseValidationError extends Error {
16
- constructor(message) {
17
- super(`Response validation failed: ${message}`);
18
- this.name = "ResponseValidationError";
19
- }
20
- }
11
+ import {
12
+ BadRequestException
13
+ } from "@vertz/server";
21
14
  function validateSchema(schema, value, label) {
22
15
  try {
23
16
  return schema.parse(value);
@@ -37,10 +30,20 @@ function createTestApp() {
37
30
  function buildHandler(perRequest) {
38
31
  const trie = new Trie;
39
32
  const realServices = new Map;
40
- for (const { module } of registrations) {
33
+ for (const { module, options } of registrations) {
41
34
  for (const service of module.services) {
42
35
  if (!realServices.has(service)) {
43
- realServices.set(service, service.methods({}, undefined));
36
+ let parsedOptions = {};
37
+ if (service.options && options) {
38
+ const parsed = service.options.safeParse(options);
39
+ if (parsed.success) {
40
+ parsedOptions = parsed.data;
41
+ } else {
42
+ throw new Error(`Invalid options for service ${service.moduleName}: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
43
+ }
44
+ }
45
+ const env = {};
46
+ realServices.set(service, service.methods({}, undefined, parsedOptions, env));
44
47
  }
45
48
  }
46
49
  }
@@ -197,9 +200,18 @@ function createTestApp() {
197
200
  };
198
201
  return app;
199
202
  }
203
+
204
+ class ResponseValidationError extends Error {
205
+ constructor(message) {
206
+ super(`Response validation failed: ${message}`);
207
+ this.name = "ResponseValidationError";
208
+ }
209
+ }
200
210
  // src/test-service.ts
201
211
  function createTestService(serviceDef) {
202
212
  const serviceMocks = new Map;
213
+ let providedOptions = {};
214
+ let providedEnv = {};
203
215
  async function resolve() {
204
216
  const deps = {};
205
217
  if (serviceDef.inject) {
@@ -211,14 +223,40 @@ function createTestService(serviceDef) {
211
223
  deps[name] = mock;
212
224
  }
213
225
  }
214
- const state = serviceDef.onInit ? await serviceDef.onInit(deps) : undefined;
215
- return serviceDef.methods(deps, state);
226
+ let options = {};
227
+ if (serviceDef.options) {
228
+ const parsed = serviceDef.options.safeParse(providedOptions);
229
+ if (parsed.success) {
230
+ options = parsed.data;
231
+ } else {
232
+ throw new Error(`Invalid options: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
233
+ }
234
+ }
235
+ let env = {};
236
+ if (serviceDef.env) {
237
+ const parsed = serviceDef.env.safeParse(providedEnv);
238
+ if (parsed.success) {
239
+ env = parsed.data;
240
+ } else {
241
+ throw new Error(`Invalid env: ${parsed.error.issues.map((i) => i.message).join(", ")}`);
242
+ }
243
+ }
244
+ const state = serviceDef.onInit ? await serviceDef.onInit(deps, options, env) : undefined;
245
+ return serviceDef.methods(deps, state, options, env);
216
246
  }
217
247
  const builder = {
218
248
  mock(service, impl) {
219
249
  serviceMocks.set(service, impl);
220
250
  return builder;
221
251
  },
252
+ options(opts) {
253
+ providedOptions = opts;
254
+ return builder;
255
+ },
256
+ env(envVars) {
257
+ providedEnv = envVars;
258
+ return builder;
259
+ },
222
260
  then(onfulfilled, onrejected) {
223
261
  return resolve().then(onfulfilled, onrejected);
224
262
  }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@vertz/testing",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
+ "description": "Testing utilities for Vertz applications",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "https://github.com/vertz-dev/vertz.git",
@@ -30,14 +31,16 @@
30
31
  "typecheck": "tsc --noEmit"
31
32
  },
32
33
  "dependencies": {
33
- "@vertz/core": "workspace:*"
34
+ "@vertz/core": "workspace:*",
35
+ "@vertz/server": "workspace:*"
34
36
  },
35
37
  "devDependencies": {
36
38
  "@types/node": "^25.2.1",
37
39
  "@vertz/schema": "workspace:*",
40
+ "@vitest/coverage-v8": "^4.0.18",
38
41
  "bunup": "latest",
39
42
  "typescript": "^5.7.0",
40
- "vitest": "^3.0.0"
43
+ "vitest": "^4.0.18"
41
44
  },
42
45
  "engines": {
43
46
  "node": ">=22"