@znight/testing 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ugho STEPHAN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,442 @@
1
- # Temporary Holding Version
1
+ # @znight/testing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Testing utilities for [Znight](../../README.md) applications: unit tests with auto-mocked
4
+ dependencies, end-to-end tests with in-memory requests.
5
+
6
+ - **`TestBed`**: unit tests. Builds one provider with its real (generated) factory and auto-mocks its
7
+ direct dependencies.
8
+ - **`TestApp`**: end-to-end tests. A started `Application` without signal handlers, with overrides
9
+ applied last, only what the tested entry points need instantiated, and in-memory HTTP requests
10
+ (`app.inject()`).
11
+ - **Deep auto-mocks** and a **Jest-compatible spy**, cooperating with `bun:test`, Vitest and Jest.
12
+
13
+ ```sh
14
+ # bun
15
+ bun add -d @znight/testing
16
+
17
+ # npm
18
+ npm install -D @znight/testing
19
+ ```
20
+
21
+ Runs on Node.js >= 24 and Bun >= 1.3 (ESM only), with any test runner.
22
+
23
+ **Documentation:** [`@znight/testing`](https://github.com/znight-org/znight/blob/main/docs/packages/testing.md)
24
+ and the [testing guide](https://github.com/znight-org/znight/blob/main/docs/guide/testing.md).
25
+ **Examples:** every [example application](https://github.com/znight-org/znight/tree/main/examples)
26
+ ships its tests (`bun test`): unit tests with `TestBed`, HTTP tests with `TestApp` in
27
+ [`rest-api`](https://github.com/znight-org/znight/tree/main/examples/rest-api), message consumers in
28
+ [`worker`](https://github.com/znight-org/znight/tree/main/examples/worker), event streams and a
29
+ virtual clock in [`uptime-monitor`](https://github.com/znight-org/znight/tree/main/examples/uptime-monitor).
30
+
31
+ ## Generated metadata first
32
+
33
+ Znight resolves dependencies at build time: `TestBed` and `TestApp` read the metadata generated by
34
+ `@znight/compiler` (`__ZNIGHT_METADATA__`, `src/.znight/container.generated.ts`). It is not committed,
35
+ so make sure it is fresh before the tests run:
36
+
37
+ | Test runner | Setup |
38
+ | ----------- | ----------------------------------------------------------------------------------------------- |
39
+ | `bun test` | Preload the compiler: `bunfig.toml` → `[test] preload = ["@znight/compiler/bun"]` |
40
+ | Vitest | Add the `znight()` Vite plugin to `vitest.config.ts` (it regenerates the metadata on every run) |
41
+ | Anything | Run `znight generate` before the tests (`"pretest": "znight generate"`), `--check` fails in CI |
42
+
43
+ ```ts
44
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
45
+ ```
46
+
47
+ Without metadata, or for a class missing from it, `TestBed` reports a `ZNIGHT001` naming the class
48
+ and how to regenerate the metadata. A metadata format mismatch is a `ZNIGHT018`. Stale metadata (a
49
+ constructor changed since the last generation) calls the factory with outdated dependencies: keep
50
+ the preload / plugin / `pretest` script in place.
51
+
52
+ ## TestBed
53
+
54
+ ```ts
55
+ import { describe, expect, test } from 'bun:test'
56
+ import { TestBed } from '@znight/testing'
57
+ import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
58
+
59
+ describe('UserService', () => {
60
+ test('finds a user', async () => {
61
+ const { instance, mocks } = await TestBed.create(UserService, { metadata })
62
+
63
+ mocks.get(UserRepository).findById.mockResolvedValue({ id: '1', name: 'Ada' })
64
+
65
+ expect(await instance.findById('1')).toEqual({ id: '1', name: 'Ada' })
66
+ expect(mocks.get(UserRepository).findById).toHaveBeenCalledWith('1')
67
+ expect(mocks.get(Logger).info).toHaveBeenCalled()
68
+ })
69
+ })
70
+ ```
71
+
72
+ `TestBed.create(target, options)` builds `target` (a class, a token, an alias such as an interface
73
+ or abstract base, or a unique display name) with its generated factory, then returns:
74
+
75
+ | Field | Description |
76
+ | ----------- | --------------------------------------------------------------------------------------------------- |
77
+ | `instance` | The target, built with real code. Async factories are awaited. |
78
+ | `mocks` | The auto-mocks: `get(key)`, `has(key)`, `tokens()`, `clearAll()`, `resetAll()`. |
79
+ | `container` | A `Container` holding the target and everything injected into it (for code resolving dependencies). |
80
+
81
+ Lifecycle hooks (`onStart`...) are not called: call them yourself when the test needs them.
82
+
83
+ ### What gets injected
84
+
85
+ Only **direct** dependencies are replaced, by kind:
86
+
87
+ | Dependency | Injected |
88
+ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
89
+ | Class, interface, abstract class, `@Provides` value, external | A deep auto-mock (`mocks.get(...)`) |
90
+ | Configuration class (`@Config`) | A real instance: field initializers, then `@Default` values (converted to the field type, like at runtime), then `config` values |
91
+ | Qualified value without class (`@Inject('dbUrl')`, primitives) | The `provide` value, otherwise a `ZNIGHT001` error naming it |
92
+ | Optional (`@Optional()`, `T \| undefined`) | `undefined`, unless provided |
93
+ | Multi (`T[]`) | `[]`, plus the members that are provided |
94
+ | `Lazy<T>` | `{ get: () => mock }` (the same mock as `mocks.get(T)`) |
95
+ | Interceptor (`@Intercept`, `@Logged`...) | Nothing, and interceptors are not applied (see `aop`) |
96
+
97
+ A dependency used several times (e.g. directly and lazily) always receives the same value.
98
+ Qualified **class** providers (`@Named('replica') class Replica`) are auto-mocked: their type is known.
99
+
100
+ ### Options
101
+
102
+ | Option | Type | Description |
103
+ | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
104
+ | `metadata` | `ZnightMetadata` | **Required.** The generated metadata. |
105
+ | `provide` | `Map` / `[key, value][]` / record | Values injected instead of auto-mocks. Keys: class, token, alias or display name (`{ 'AppFactory.dbUrl': 'postgres://' }`). |
106
+ | `overrides` | same as `provide` | Like `provide` (and wins over it), but a provider definition `{ scope, deps, factory }` is **built**, its own dependencies auto-mocked. |
107
+ | `config` | record / `[ConfigClass, values][]` | Values of configuration classes, keyed by class, token, display name or `@Config` prefix. |
108
+ | `aop` | `'none'` \| `'real'` | `'real'` builds the interceptors (their dependencies auto-mocked, or taken from `provide`/`overrides`) and applies them. Default `'none'`. |
109
+ | `deep` | `boolean` | Unconfigured methods of the auto-mocks return deep mocks instead of `undefined`. Default `false`. |
110
+ | `mockFn` | `'auto'` \| `'builtin'` \| `vi.fn`... | Spy implementation. Default: auto-detected (see [Spies](#spies)). |
111
+
112
+ ```ts
113
+ const { instance } = await TestBed.create(UserService, {
114
+ metadata,
115
+ provide: { 'DbFactory.databaseUrl': 'postgres://test' }, // qualified primitive
116
+ config: { database: { host: 'db.test', port: 5433 } }, // @Config('database')
117
+ overrides: [
118
+ // build the real repository; its own dependencies (Database...) are auto-mocked
119
+ [UserRepository, metadata.providers['src/users/pg-user.repository.ts#PgUserRepository']],
120
+ ],
121
+ aop: 'real', // apply @Logged / @Intercept with real interceptors
122
+ })
123
+ ```
124
+
125
+ Errors are explicit: an unknown key in `provide`/`overrides` (`ZNIGHT001`), an unknown configuration
126
+ class or property (`ZNIGHT012`), an ambiguous display name (`ZNIGHT008`), a failing factory
127
+ (`ZNIGHT015` with the original `cause` and the source location).
128
+
129
+ ### `mocks`
130
+
131
+ `mocks.get(key)` accepts a class (typed result: `Mocked<T>`), a token (`TypedToken<T>` gives a typed
132
+ result), an alias (`UserRepository` for `PgUserRepository`) or a display name (`'Logger'`, untyped deep
133
+ mock unless you pass the type: `mocks.get<Logger>('Logger')`). When the dependency was not
134
+ auto-mocked, the error says why (provided, configuration class, optional, interceptor...).
135
+
136
+ `mocks.clearAll()` forgets every recorded call; `mocks.resetAll()` also forgets programmed behaviours
137
+ and restores the initial implementations.
138
+
139
+ ## TestApp
140
+
141
+ ```ts
142
+ import { expect, test } from 'bun:test'
143
+ import { HttpPlugin } from '@znight/http'
144
+ import { createMock, override, TestApp } from '@znight/testing'
145
+
146
+ test('GET /users/:id', async () => {
147
+ const repository = createMock(UserRepository)
148
+ repository.findById.mockResolvedValue({ id: '1', name: 'Ada' })
149
+
150
+ await using app = await TestApp.create({
151
+ metadata,
152
+ plugins: [HttpPlugin.configure({ port: 0 })], // any adapter; port 0 = a free port
153
+ overrides: [override(UserRepository, repository)],
154
+ })
155
+
156
+ const response = await app.inject({ method: 'GET', url: '/users/1' })
157
+ expect(response.status).toBe(200)
158
+ expect(response.json()).toEqual({ id: '1', name: 'Ada' })
159
+ })
160
+ ```
161
+
162
+ `TestApp.create(options)` runs `Application.create()` then `app.start()`, with signal handlers
163
+ disabled. Overrides are applied after the plugins registered (highest precedence) and the real
164
+ container is used: interceptors, scopes and lifecycle hooks all run.
165
+
166
+ `app.inject()` needs no listening server: configure the HTTP plugin with `port: 0` (a free port) so
167
+ parallel test files never collide, or pass `start: false` to stop after `onBootstrap`.
168
+
169
+ The same goes for every server a plugin starts on a fixed port. Once `prom-client` is installed,
170
+ `ObservabilityPlugin` from `@znight/observability` serves `/metrics` on port 9464 by default: a
171
+ second test file, or `znight dev`, then fails to start. Disable that server in tests, or give it a
172
+ free port when a test scrapes it:
173
+
174
+ ```ts
175
+ await using app = await TestApp.create({
176
+ metadata,
177
+ plugins: [
178
+ ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: { server: false } }), // or server: { port: 0 }
179
+ HttpPlugin.configure({ port: 0 }),
180
+ ],
181
+ })
182
+ ```
183
+
184
+ `TestApp` never changes the options of the plugins it is given (they are configured instances):
185
+ when the start fails because a port is taken (`EADDRINUSE` anywhere in the `cause` chain), the error
186
+ carries a hint naming these options.
187
+
188
+ | Option | Type | Description |
189
+ | ----------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `metadata` | `ZnightMetadata` | **Required.** The generated metadata. |
191
+ | `plugins` | `Plugin[]` | Plugins, as in `Application.create`. |
192
+ | `providers` | `Map` / `[key, value][]` / record | Values for externals (`Logger`, `ConfigSource`...), as in `Application.create`. |
193
+ | `overrides` | `Map` / `[key, value][]` / record | Values or provider definitions replacing providers, applied last. Overridden providers are instantiated at boot. |
194
+ | `only` | `'all'` \| `(Class \| Token)[]` | Providers instantiated at boot (with their dependencies), plus the overrides and the plugin roots (below). Default: the entry points of the metadata (HTTP controllers with their guards and interceptors, message consumers, hosts of scheduled jobs), or every provider when there is none. |
195
+ | `start` | `boolean` | Run `app.start()` (plugins listen/subscribe). Default `true`; `false` stops after `onBootstrap`. |
196
+ | `logger` | `CoreLogger` | Default: a console logger writing warnings and errors only. |
197
+ | `shutdown` | `{ timeoutMs?, hookTimeoutMs? }` | Shutdown timeouts. |
198
+
199
+ Keys of `providers` and `overrides` may be classes, tokens, aliases or unique display names
200
+ (`'UserRepository'`), like in `TestBed`; other strings are used as tokens (values for tokens
201
+ registered at runtime by plugins).
202
+
203
+ Limiting the boot to the closure of the entry points keeps tests fast and lets them ignore unrelated
204
+ providers (and their missing externals). Other providers are still created on first use (synchronous
205
+ factories only). The hosts of scheduled jobs (`@Cron`, `@Interval`, `@Timeout` of
206
+ `@znight/schedule`) are entry points even when the test does not run the scheduler, so a test that
207
+ only exercises HTTP routes can pass `SchedulePlugin.configure({ enabled: false })` and still trigger
208
+ a job through the registry.
209
+
210
+ Providers a plugin resolves while it bootstraps without the metadata making them entry points (the
211
+ global `guards`, `interceptors` and `exceptionMappers` of `HttpPlugin.configure({...})`) are
212
+ instantiated at boot too, with their dependencies, when the plugin lists them in the optional
213
+ `initRoots(context)` method of `Plugin` (`@znight/core`), as `HttpPlugin` does (see
214
+ [Compiler contract](#compiler-contract)). These plugin roots complete the entry points and never
215
+ restrict the boot by themselves: without any entry point, every provider is created.
216
+
217
+ Options computed from the container (`HttpPlugin.configure(c => ({...}))`) cannot be listed before
218
+ the boot: when such a provider (or one of its dependencies) has an async factory, the boot fails
219
+ with `ZNIGHT015` ("cannot create ... on demand: its factory is async"); add it to `only`, or pass
220
+ `only: 'all'`:
221
+
222
+ ```ts
223
+ await using app = await TestApp.create({
224
+ metadata: __ZNIGHT_METADATA__,
225
+ plugins: [HttpPlugin.configure(c => ({ port: 0, prefix: c.get(AppConfig).prefix, guards: [AuthGuard] }))],
226
+ only: [UserController, AuthGuard], // AuthGuard needs the async JwksStore
227
+ })
228
+ ```
229
+
230
+ | Member | Description |
231
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
232
+ | `inject(request)` | In-memory request through the HTTP plugin: `{ method = 'GET', url, headers?, body?, query?, cookies?, signedCookies? }`. |
233
+ | `injectSse(request)` | An event stream (Server-Sent Events) read in memory like `EventSource` (see below). |
234
+ | `injectStream(request)` | In-memory request resolving at the response headers, its `body` a `ReadableStream` read as it is produced. |
235
+ | `get(key)` | `app.container.get(key)` (class, token, alias or display name). |
236
+ | `close()` | Stops the application (idempotent). Also `await using app = await TestApp.create(...)`. |
237
+ | `app` / `container` | The `Application` and its `Container`. |
238
+
239
+ `inject()` resolves with `{ status, headers, body, bytes, cookies, json() }` (`body` decoded as
240
+ UTF-8, `bytes` the raw body, for downloads, `cookies` the `set-cookie` headers parsed by name); it
241
+ rejects with `ZNIGHT001` when the application has no HTTP plugin and with `ZNIGHT021` once closed.
242
+ The plugin encodes the request `body`: strings and bytes as is, a `FormData` as
243
+ `multipart/form-data` (files included), a `Blob` / `File` with its type, other values as JSON.
244
+
245
+ Cookies make round trips: `cookies` sends values in a `cookie` header (after any `cookie` header of
246
+ `headers`), `signedCookies` sends values the HTTP plugin signs with its current secret first (a valid
247
+ `@SignedCookie`), and each `response.cookies[name]` (`SetCookieInfo`: `value`, `raw`, `expires`,
248
+ `maxAge`, `domain`, `path`, `secure`, `httpOnly`, `sameSite`, `partitioned`, `priority`) can be sent
249
+ back as is:
250
+
251
+ ```ts
252
+ const login = await app.inject({ method: 'POST', url: '/auth/login', body: { email, password } })
253
+ expect(login.cookies['session']).toMatchObject({ httpOnly: true, secure: true, sameSite: 'lax' })
254
+
255
+ const me = await app.inject({ url: '/auth/me', cookies: { session: login.cookies['session']?.value ?? '' } })
256
+ const forged = await app.inject({ url: '/auth/me', signedCookies: { session: 'u-1' } })
257
+ ```
258
+
259
+ Event streams (`@Sse` routes, `HttpResponse.sse()`) never end, so `inject()` rejects them; read
260
+ them with `injectSse()`. Events are read only when asked for, and leaving the `await using` block
261
+ (or `close()`) disconnects: the server sees `ctx.signal` abort.
262
+
263
+ ```ts
264
+ test('order events', async () => {
265
+ await using stream = await app.injectSse({ url: '/orders/o-1/events' })
266
+ expect(stream.status).toBe(200)
267
+ expect(stream.retry).toBe(5_000)
268
+
269
+ app.get(OrderHub).publish({ orderId: 'o-1', sequence: 42, type: 'status', status: 'paid' })
270
+ const event = await stream.next() // rejects after 2 000 ms by default
271
+ expect(event).toMatchObject({ event: 'status', id: '42' })
272
+ expect(event.json()).toMatchObject({ status: 'paid' })
273
+
274
+ const resumed = await stream.reconnect() // the same request plus `last-event-id: 42`, like EventSource
275
+ await resumed.close()
276
+ })
277
+ ```
278
+
279
+ `injectSse()` resolves with `{ status, headers, cookies, body, json(), next({ timeoutMs? }),
280
+ take(count), comments, lastEventId, retry, done, close(), reconnect() }`, an async iterable of
281
+ `{ event, data, id, json() }` messages; `body` is the whole body when the answer is not an event
282
+ stream (a 404, a 204). `injectStream()` resolves with `{ status, headers, cookies, body }` (cancel
283
+ `body`, or abort `signal`, to disconnect). Both reject with `ZNIGHT001` when the plugin named `'http'`
284
+ does not implement them (hint: `HttpPlugin` of `@znight/http` ≥ 0.1.0, the release that ships
285
+ streaming), and with `ZNIGHT021` once closed.
286
+
287
+ ```ts
288
+ import { readFile } from 'node:fs/promises'
289
+
290
+ test('uploads and downloads', async () => {
291
+ const form = new FormData()
292
+ form.append('title', 'Me')
293
+ form.append('avatar', new File([await readFile('test/fixtures/me.png')], 'me.png', { type: 'image/png' }))
294
+
295
+ const uploaded = await app.inject({ method: 'POST', url: '/profile/avatar', body: form })
296
+ expect(uploaded.status).toBe(201)
297
+
298
+ const download = await app.inject({ url: '/reports/q1.pdf', headers: { range: 'bytes=0-99' } })
299
+ expect(download.status).toBe(206)
300
+ expect(download.bytes.byteLength).toBe(100)
301
+ })
302
+ ```
303
+
304
+ ## Mocks
305
+
306
+ ```ts
307
+ import { createMock } from '@znight/testing'
308
+
309
+ const repository = createMock(UserRepository) // Mocked<UserRepository>, instanceof UserRepository
310
+ const logger = createMock<Logger>() // from an interface
311
+ const client = createMock<HttpClient>({ baseUrl: 'http://test', get: async () => ({ ok: true }) })
312
+ const db = createMock<Database>(undefined, { deep: true }) // db.select().from('users')
313
+ ```
314
+
315
+ - Every property read returns a spy, created on first access and then stable. Spies are deep:
316
+ `mock.pool.query.mockResolvedValue(rows)`.
317
+ - Unconfigured methods return `undefined`, or with `{ deep: true }` a deep mock (the same one on
318
+ every call, so fluent APIs work).
319
+ - Mocks are **not thenable** and inert for serialisers: `then`, `toJSON`, `asymmetricMatch`,
320
+ `$$typeof`, `nodeType`, `tagName`, `__esModule`, `@@...` and every symbol (`Symbol.toPrimitive`,
321
+ `Symbol.iterator`...) read as `undefined`. `await mock`, `JSON.stringify(mock)` (`{}`), `String(mock)`
322
+ and equality matchers behave.
323
+ - `Object.prototype` members (`toString`, `hasOwnProperty`...) are the real ones.
324
+ - Assigned properties win: `mock.enabled = false`. With a class, getters read as `undefined` (not a
325
+ truthy spy) until assigned.
326
+ - Functions of the partial implementation become spies running that implementation.
327
+
328
+ | Helper | Description |
329
+ | ---------------------------------------- | ------------------------------------------------------------ |
330
+ | `createMock(Class?, partial?, options?)` | Deep auto-mock. Options: `name`, `deep`, `mockFn`. |
331
+ | `isMock(value)` | Whether `value` is a mock (or one of its spies). |
332
+ | `clearMock(mock)` | `mockClear()` on every spy of the mock. |
333
+ | `resetMock(mock)` | Back to the initial state (programmed behaviours forgotten). |
334
+
335
+ ## Spies
336
+
337
+ The spy implementation is detected when a mock is created:
338
+
339
+ 1. Vitest: `globalThis.vi.fn` (Vitest with `globals: true`);
340
+ 2. Jest: `globalThis.jest.fn`;
341
+ 3. `bun:test`: `mock` (loaded only when running on Bun). Bun's `expect(...).toHaveBeenCalled()`
342
+ only accepts genuine Bun mocks: they are used as is (deep members are added without wrapping
343
+ them in a proxy);
344
+ 4. otherwise the built-in spy, `createSpy()`: dependency-free and Jest-compatible (`mock.calls`,
345
+ `mock.results`, `mock.instances`, `mock.contexts`, `mock.invocationCallOrder`, `mock.lastCall`,
346
+ `mockReturnValue(Once)`, `mockResolvedValue(Once)`, `mockRejectedValue(Once)`,
347
+ `mockImplementation(Once)`, `mockReturnThis`, `mockName`, `mockClear`, `mockReset`,
348
+ `mockRestore`). It is recognised by Jest's and Vitest's `expect` (`_isMockFunction`).
349
+
350
+ Force one with the `mockFn` option, or for the whole test suite in a setup file:
351
+
352
+ ```ts
353
+ // vitest.setup.ts (Vitest without globals)
354
+ import { vi } from 'vitest'
355
+ import { setDefaultMockFn } from '@znight/testing'
356
+
357
+ setDefaultMockFn(vi.fn) // vi.clearAllMocks() / vi.resetAllMocks() then apply to Znight mocks too
358
+ ```
359
+
360
+ `setDefaultMockFn()` without argument restores auto-detection; `'builtin'` forces the built-in spy.
361
+ Depending on the framework, a spy's own `mockReset()` may drop the implementation it was created
362
+ with (`bun:test` does, the built-in spy restores it); `resetMock()` / `mocks.resetAll()` always restore
363
+ it (partial implementations, deep returns).
364
+
365
+ ## Hand-written metadata
366
+
367
+ For tests that do not run the compiler (plugins, libraries, edge cases), write the metadata by hand:
368
+
369
+ ```ts
370
+ import { createMetadata, defineTestMetadata, testToken } from '@znight/testing'
371
+
372
+ // class-first: tokens derived from class names (test#UserService), factories = new Class(...deps)
373
+ const metadata = defineTestMetadata({
374
+ providers: [
375
+ PgUserRepository,
376
+ { class: UserService, deps: [PgUserRepository, '@znight/observability#Logger', { lazy: AuditService }] },
377
+ { token: 'src/db.ts#DbFactory.url', factory: () => 'postgres://', name: 'url' },
378
+ ],
379
+ aliases: [[UserRepository, PgUserRepository]],
380
+ externals: ['@znight/observability#Logger'],
381
+ extensions: { http: { controllers: [{ token: testToken(UserController), basePath: '/users', routes: [] }] } },
382
+ })
383
+
384
+ // exact compiler shape (resolution order computed)
385
+ const raw = createMetadata({
386
+ 'src/clock.ts#Clock': { scope: 'singleton', class: Clock, deps: [], factory: () => new Clock() },
387
+ })
388
+ ```
389
+
390
+ | `TestProvider` field | Description |
391
+ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
392
+ | `class` | Provider class (`container.get(Class)`). |
393
+ | `token` | Default `<tokenPrefix>#<ClassName>` (`tokenPrefix` defaults to `test`). |
394
+ | `deps` | Classes or tokens, `{ token, optional: true }`, `{ multi: [...] }`, `{ lazy }`. |
395
+ | `factory` | Default `deps => new Class(...deps)`. |
396
+ | `scope`, `async`, `interceptors`, `dispose`, `name`, `primary`, `source` | As in `ProviderDefinition`. |
397
+
398
+ ## Overrides helpers
399
+
400
+ ```ts
401
+ import { override, overrideFactory } from '@znight/testing'
402
+
403
+ overrides: [
404
+ override(Clock, fixedClock), // type-checked against Clock
405
+ overrideFactory(UserRepository, ([db]) => new InMemoryUserRepository(db), { deps: ['src/db.ts#Database'] }),
406
+ ]
407
+ ```
408
+
409
+ ## Compiler contract
410
+
411
+ `@znight/testing` exports **no helper called by generated code**: the compiler never imports it. It
412
+ consumes the generated metadata through the documented runtime shapes only (architecture §3 and §9),
413
+ without importing any feature package:
414
+
415
+ | Metadata | Used by |
416
+ | -------------------------------------------------------------------------------------------------- | -------------------------------------------- |
417
+ | `providers[token].{ class, deps, factory, async, interceptors, name, source }` | `TestBed` (target factory, dependency kinds) |
418
+ | `aliases`, `externals` | key lookups, error messages |
419
+ | `extensions.config.configs[].{ token, prefix, fields[].{ property, type, default } }` | `TestBed` configuration instances |
420
+ | `extensions.http.controllers[].{ token, guards, interceptors, routes[].{ guards, interceptors } }` | `TestApp` boot roots |
421
+ | `extensions.messaging.consumers[].token` | `TestApp` boot roots |
422
+ | `extensions.schedule.jobs[].token` | `TestApp` boot roots |
423
+
424
+ Runtime contract with `@znight/http`: the plugin named `'http'` exposes
425
+ `inject(request: { method: string; url: string; headers?; body?; query?; cookies?; signedCookies? }): Promise<{ status: number; headers; body: string; bytes?: Uint8Array; cookies?: Record<string, SetCookieInfo>; json<T>(): T }>`
426
+ (`HttpInjector`, `HttpInjectResult`), dispatching in memory. `TestApp` forwards every request field
427
+ as is (signing `signedCookies` is the plugin's job), returns the plugin's response as is when it has
428
+ `bytes` and `cookies`, and otherwise derives `bytes` from `body` and returns no cookies (`{}`).
429
+ `TestApp.injectStream()` / `injectSse()` delegate the same way to the optional
430
+ `injectStream(request)` / `injectSse(request)` members of that plugin (`HttpStreamInjector`:
431
+ `HttpPlugin` of `@znight/http` ≥ 0.1.0), the method defaulting to `GET`; the response shapes are
432
+ `InjectStreamResponse` and `InjectSseResponse` (structural copies of the `@znight/http` types).
433
+
434
+ Runtime contract with every plugin: the optional `initRoots(context: { metadata }): readonly ProviderKey[]`
435
+ member of `Plugin` (`@znight/core`; `PluginInitRoots` names a plugin implementing it) returns the
436
+ providers (classes or tokens) the plugin resolves at boot that the metadata does not make entry
437
+ points: the classes given in its options, or providers it derives from its own metadata section
438
+ (`context.metadata`). `TestApp.create()` calls it once, before creating the application, and adds
439
+ the keys that designate providers of the metadata to the providers instantiated at boot (other keys
440
+ are ignored: the plugin reports them); it rejects with a `TypeError` when the result is not an
441
+ array. `HttpPlugin` returns the classes of its `guards`, `interceptors` and `exceptionMappers`
442
+ options (nothing when the options are a function of the container).