@znight/testing 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +441 -2
- package/dist/index.d.mts +697 -0
- package/dist/index.mjs +1561 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -4
- package/src/config.ts +214 -0
- package/src/define-metadata.ts +255 -0
- package/src/extensions.ts +109 -0
- package/src/index.ts +58 -0
- package/src/metadata-index.ts +161 -0
- package/src/mock-fn.ts +83 -0
- package/src/mock-registry.ts +118 -0
- package/src/mock.ts +270 -0
- package/src/overrides.ts +54 -0
- package/src/spy.ts +246 -0
- package/src/test-app.ts +551 -0
- package/src/testbed-builder.ts +366 -0
- package/src/testbed.ts +97 -0
- package/src/types.ts +107 -0
- package/src/utils.ts +37 -0
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
|
-
#
|
|
1
|
+
# @znight/testing
|
|
2
2
|
|
|
3
|
-
|
|
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).
|