@basaltkit/testing 1.1.0 → 1.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/README.md +97 -14
- package/package.json +14 -10
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Testing kit for Basalt applications: boots the application in memory with `creat
|
|
|
12
12
|
|
|
13
13
|
Testing a "real" web application is a lot of work: you'd have to start the server on a port, authenticate a real user, wait for real emails, and wait days to see a subscription expire. None of this is practical in an automated test, which should run in milliseconds and always produce the same result.
|
|
14
14
|
|
|
15
|
-
This package solves the problem with four tools. `createTestApp` boots your application and
|
|
15
|
+
This package solves the problem with four tools. `createTestApp` boots your application and dispatches HTTP requests straight into it — by default in-process through Fastify's `inject()`, with no network at all — and lets you "pretend" the request comes from a specific user or tenant (`actingAs` / `asTenant`), without going through login. Pass `adapter: 'express'` or `adapter: 'hono'` and the *same* suite runs against those adapters instead. A mail **fake** (a fake object that replaces a real service during tests), `fakeMailer`, records emails instead of sending them; a queue fake, `fakeQueue`, captures jobs instead of running them — both with Laravel-style assertions (`assertSent`, `assertDispatched`). Finally, `time` shifts the clock (`time.travel('15d')`) so you can test expirations and deadlines without waiting.
|
|
16
16
|
|
|
17
17
|
Everything works with any test runner (Vitest, Jest, node:test…), because nothing here depends on the runner.
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ Everything works with any test runner (Vitest, Jest, node:test…), because noth
|
|
|
22
22
|
pnpm add -D @basaltkit/testing
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
> Note: it depends on `@basaltkit/core`, `@basaltkit/fastify`, `@basaltkit/mailer`, `@basaltkit/queue`, and `fastify`. Projects created with `create-basalt` already include `@basaltkit/testing` in `devDependencies`.
|
|
25
|
+
> Note: it depends on `@basaltkit/core`, `@basaltkit/fastify`, `@basaltkit/mailer`, `@basaltkit/queue`, and `fastify`. `@basaltkit/express` and `@basaltkit/hono` are **optional peers** — you only need them if you pass `adapter: 'express'` / `adapter: 'hono'`. Projects created with `create-basalt` already include `@basaltkit/testing` in `devDependencies`.
|
|
26
26
|
|
|
27
27
|
## Get started in 5 minutes
|
|
28
28
|
|
|
@@ -79,11 +79,67 @@ await app.patch(`/projects/${id}`, { name: 'Renamed' })
|
|
|
79
79
|
await app.delete(`/projects/${id}`)
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
On the default adapter the response is a Fastify `LightMyRequestResponse`; on Express and
|
|
83
|
+
Hono it is a `TestResponse` built from a real `fetch` Response. Both expose the same
|
|
84
|
+
surface — `.statusCode`, `.headers`, `.body`, `.json()` — so assertions are portable.
|
|
85
|
+
(`json()` is synchronous on both: the body is already read.) Multiple `Set-Cookie` headers
|
|
86
|
+
arrive as a `string[]` under `headers['set-cookie']`.
|
|
87
|
+
|
|
88
|
+
### Running the same suite on every adapter — `adapter`
|
|
89
|
+
|
|
90
|
+
`createTestApp` doesn't choose your HTTP adapter; **you** still pass the adapter plugin in
|
|
91
|
+
`plugins`. The `adapter` option only tells the harness *how to dispatch* a request:
|
|
92
|
+
|
|
93
|
+
| `adapter` | How requests are dispatched | Socket? | Extra install |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| `'fastify'` (default) | Fastify's `inject()` — in-process | no | nothing |
|
|
96
|
+
| `'express'` | `listen(0)` on `127.0.0.1` + `fetch` (Express has no in-process inject); the socket is closed by `shutdown()` | yes, ephemeral | `@basaltkit/express` + `express` |
|
|
97
|
+
| `'hono'` | `hono.fetch(new Request(…))` — in-process | no | `@basaltkit/hono` + `hono` |
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { expressPlugin } from '@basaltkit/express'
|
|
101
|
+
import { createTestApp } from '@basaltkit/testing'
|
|
102
|
+
|
|
103
|
+
const app = await createTestApp({
|
|
104
|
+
adapter: 'express',
|
|
105
|
+
plugins: [expressPlugin({ routes: [health] })],
|
|
106
|
+
})
|
|
107
|
+
const res = await app.get('/health') // identical assertions to the Fastify run
|
|
108
|
+
await app.shutdown() // also closes the listening socket
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The whole point is that the assertions don't change. Impersonation, guards and enrichers
|
|
112
|
+
all go through the framework-neutral `'http:enrichers'` / `'http:guards'` buckets, so they
|
|
113
|
+
behave identically on all three — which makes a parameterized suite the cheapest possible
|
|
114
|
+
proof that your app really is adapter-agnostic:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
describe.each(['fastify', 'express', 'hono'] as const)('on %s', (adapter) => {
|
|
118
|
+
it('serves /health', async () => {
|
|
119
|
+
const app = await createTestApp({ adapter, plugins: [pluginFor(adapter)] })
|
|
120
|
+
expect((await app.get('/health')).statusCode).toBe(200)
|
|
121
|
+
await app.shutdown()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Two details worth knowing. The Fastify driver connects **lazily**, on the first request, so
|
|
127
|
+
an app booted with no HTTP plugin at all (a mailer- or queue-only test) still works. And
|
|
128
|
+
`TestApp.server` returns the raw `FastifyInstance` — meaningful only on the default
|
|
129
|
+
adapter; on Express/Hono resolve `EXPRESS` / `HONO` from `app.container` instead.
|
|
130
|
+
|
|
131
|
+
If you ask for an adapter whose package isn't installed, you get an actionable error
|
|
132
|
+
naming the two packages to add, not a bare `ERR_MODULE_NOT_FOUND`.
|
|
83
133
|
|
|
84
134
|
### Faking users and tenants (impersonation)
|
|
85
135
|
|
|
86
|
-
`createTestApp`
|
|
136
|
+
`createTestApp` **prepends** a test plugin that reads the special `x-test-user` /
|
|
137
|
+
`x-test-tenant` headers and populates `ctx().user` / `ctx().tenant` — the same context your
|
|
138
|
+
application uses in production. It is registered as a `RequestEnricher` in the neutral
|
|
139
|
+
`'http:enrichers'` bucket, which is why it works the same on all three adapters. The
|
|
140
|
+
headers are parsed with **no validation whatsoever**: anyone who can set a header becomes
|
|
141
|
+
anyone. **Never register this mechanism in a real application** — it only exists inside
|
|
142
|
+
`createTestApp`.
|
|
87
143
|
|
|
88
144
|
```typescript
|
|
89
145
|
import { ctx } from '@basaltkit/core'
|
|
@@ -205,11 +261,17 @@ Exported from `@basaltkit/testing`:
|
|
|
205
261
|
|
|
206
262
|
### `createTestApp(options?): Promise<TestApp>`
|
|
207
263
|
|
|
208
|
-
Creates the application with `createApp` (the same `CreateAppOptions` as `@basaltkit/core`), prepends the impersonation plugin, calls `boot()`, and returns a `TestApp`.
|
|
264
|
+
Creates the application with `createApp` (the same `CreateAppOptions` as `@basaltkit/core`), prepends the impersonation plugin, calls `boot()`, connects the dispatch driver, and returns a `TestApp`.
|
|
209
265
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
|
266
|
+
`CreateTestAppOptions extends CreateAppOptions`:
|
|
267
|
+
|
|
268
|
+
| Option | Type | Default | Purpose |
|
|
269
|
+
| --- | --- | --- | --- |
|
|
270
|
+
| `plugins` | `BasaltPlugin[]` | `[]` | Your plugins — **including the HTTP adapter plugin**. They are registered *after* the impersonation plugin. |
|
|
271
|
+
| `config` | `Record<string, unknown>` | `{}` | Raw per-plugin config, keyed by plugin name — same as `createApp`. |
|
|
272
|
+
| `adapter` | `'fastify' \| 'express' \| 'hono'` | `'fastify'` | How requests are dispatched. `'fastify'` uses `inject()` (lazy, no socket); `'express'` listens on an ephemeral `127.0.0.1` port and fetches (closed on `shutdown()`); `'hono'` uses `hono.fetch` in-process. The non-default ones need `@basaltkit/express` / `@basaltkit/hono` installed. |
|
|
273
|
+
|
|
274
|
+
The return type follows the adapter: `Promise<TestApp>` (Fastify responses) for the default, `Promise<TestApp<TestResponse>>` when you pass an `adapter`.
|
|
213
275
|
|
|
214
276
|
### `TestApp` class
|
|
215
277
|
|
|
@@ -217,7 +279,7 @@ Creates the application with `createApp` (the same `CreateAppOptions` as `@basal
|
|
|
217
279
|
| --- | --- | --- |
|
|
218
280
|
| `app` | `BasaltApp` | The underlying application |
|
|
219
281
|
| `container` | `Container` (getter) | Dependency container — `app.container.get(TOKEN)` |
|
|
220
|
-
| `server` | `FastifyInstance` (getter) | The Fastify server (token `FASTIFY`) |
|
|
282
|
+
| `server` | `FastifyInstance` (getter) | The Fastify server (token `FASTIFY`) — meaningful **only** on the default adapter; on Express/Hono resolve `EXPRESS`/`HONO` from `container` instead |
|
|
221
283
|
| `actingAs(user)` | `(user: TestActor) => this` | Sets the default user for subsequent requests |
|
|
222
284
|
| `asTenant(tenant)` | `(tenant: string \| { id: string }) => this` | Sets the default tenant for subsequent requests |
|
|
223
285
|
| `request(method, url, options?)` | `Promise<LightMyRequestResponse>` | Generic request |
|
|
@@ -226,10 +288,12 @@ Creates the application with `createApp` (the same `CreateAppOptions` as `@basal
|
|
|
226
288
|
| `put(url, payload?, options?)` | same | PUT with body |
|
|
227
289
|
| `patch(url, payload?, options?)` | same | PATCH with body |
|
|
228
290
|
| `delete(url, options?)` | same | DELETE |
|
|
229
|
-
| `shutdown()` | `Promise<void>` |
|
|
291
|
+
| `shutdown()` | `Promise<void>` | Closes the driver (the Express socket, when there is one) and then shuts the application down. Call at the end of every test |
|
|
230
292
|
|
|
231
293
|
`TestActor`: `{ id: string; email?: string; [key: string]: unknown }`.
|
|
232
294
|
|
|
295
|
+
`TestResponse`: `{ statusCode: number; headers: Record<string, string | number | string[] | undefined>; body: string; json<T>(): T }` — the adapter-neutral response shape. Fastify's `LightMyRequestResponse` satisfies it structurally.
|
|
296
|
+
|
|
233
297
|
`TestRequestOptions`:
|
|
234
298
|
|
|
235
299
|
| Field | Type | Required? | Default | Description |
|
|
@@ -284,8 +348,14 @@ Creates the application with `createApp` (the same `CreateAppOptions` as `@basal
|
|
|
284
348
|
|
|
285
349
|
### Errors
|
|
286
350
|
|
|
287
|
-
|
|
288
|
-
|
|
351
|
+
| Error | Code | HTTP | When |
|
|
352
|
+
|---|---|---|---|
|
|
353
|
+
| `MailAssertionError` | `TEST_MAIL_ASSERTION` | — | `assertSent` found no matching mail, or `assertNothingSent` found some. The message lists what *was* sent. |
|
|
354
|
+
| `QueueAssertionError` | `TEST_QUEUE_ASSERTION` | — | `assertDispatched` found no matching job, or `assertNothingDispatched` found some. The message lists what *was* dispatched. |
|
|
355
|
+
| `Error` (plain) | — | — | `createTestApp({ adapter: 'express' \| 'hono' })` when that adapter package isn't installed. The message names both packages to add; the original module error is the `cause`. |
|
|
356
|
+
|
|
357
|
+
Both assertion errors extend `BasaltError`, so `error.code` is stable. These are test-time
|
|
358
|
+
failures — they never travel over HTTP, so they have no status.
|
|
289
359
|
|
|
290
360
|
## Common errors and solutions (FAQ)
|
|
291
361
|
|
|
@@ -293,7 +363,16 @@ Creates the application with `createApp` (the same `CreateAppOptions` as `@basal
|
|
|
293
363
|
You're missing `await app.shutdown()` at the end of the test. The application keeps resources open until it's shut down.
|
|
294
364
|
|
|
295
365
|
**`ctx().user` always comes back `undefined` in handlers.**
|
|
296
|
-
Make sure you created the app with `createTestApp` (it's the one that installs impersonation) and that you called `actingAs(...)` before the request — or passed `{ user: ... }` in that request's options. Impersonation
|
|
366
|
+
Make sure you created the app with `createTestApp` (it's the one that installs impersonation) and that you called `actingAs(...)` before the request — or passed `{ user: ... }` in that request's options. Impersonation is a `RequestEnricher` in the neutral `'http:enrichers'` bucket, so it needs *an* adapter plugin registered (`fastifyPlugin`, `expressPlugin` or `honoPlugin`) — enrichers only run inside the route pipeline.
|
|
367
|
+
|
|
368
|
+
**`createTestApp({ adapter: 'hono' }) requires @basaltkit/hono …`**
|
|
369
|
+
The optional peer isn't installed. Add `@basaltkit/hono` and `hono` (or `@basaltkit/express` and `express`) as devDependencies.
|
|
370
|
+
|
|
371
|
+
**`app.server` throws `DI_UNKNOWN_TOKEN` on Express or Hono.**
|
|
372
|
+
`server` resolves the `FASTIFY` token. On another adapter, use `app.container.get(EXPRESS)` / `app.container.get(HONO)`.
|
|
373
|
+
|
|
374
|
+
**The Express run leaves a port open after the suite.**
|
|
375
|
+
`shutdown()` closes it — make sure every test that used `adapter: 'express'` awaits it, including on the failure path.
|
|
297
376
|
|
|
298
377
|
**`Expected mail "welcome" to have been sent. Sent: (nothing)`**
|
|
299
378
|
The code never actually sent the email, or the `Mailer` used isn't the fake one. Make sure `mail.plugin` is in `createTestApp`'s `plugins` list **before** you resolve `MAILER` from the container.
|
|
@@ -310,8 +389,12 @@ No. The impersonation plugin reads headers (`x-test-user`) without any validatio
|
|
|
310
389
|
## How it connects to other modules
|
|
311
390
|
|
|
312
391
|
- **`@basaltkit/core`** — `createTestApp` wraps `createApp`; `time` uses `parseDuration`; the errors extend `BasaltError`.
|
|
313
|
-
- **`@basaltkit/fastify`** —
|
|
392
|
+
- **`@basaltkit/fastify`** — the default driver injects into the `FastifyInstance` (token `FASTIFY`).
|
|
393
|
+
- **`@basaltkit/express` / `@basaltkit/hono`** — optional peers; `adapter: 'express' | 'hono'` resolves them lazily so the default path never loads them.
|
|
394
|
+
- **`@basaltkit/http`** — impersonation is a `RequestEnricher` in the neutral `http:enrichers` bucket, which is what makes the harness adapter-agnostic.
|
|
314
395
|
- **`@basaltkit/mailer`** — `fakeMailer` registers a real `Mailer` with `MemoryMailDriver`, under the same `MAILER` token the application uses.
|
|
315
396
|
- **`@basaltkit/queue`** — `fakeQueue` uses the real `queuePlugin` with a driver that captures instead of running.
|
|
316
397
|
- **`@basaltkit/generator`** — tests generated by `basalt make:resource` use `createTestApp` from this package.
|
|
317
398
|
- **`create-basalt`** — new projects include `@basaltkit/testing` in `devDependencies` and a ready-to-run startup test.
|
|
399
|
+
|
|
400
|
+
Guides: [Testing](/guide/testing) · [Adapters](/guide/adapters)
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/testing",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=22.5.0"
|
|
6
|
+
},
|
|
4
7
|
"description": "Testing kit for Basalt apps: createTestApp with user/tenant impersonation, mail and queue fakes with assertions, and time travel.",
|
|
5
8
|
"license": "MIT",
|
|
6
9
|
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
9
13
|
"types": "./dist/index.d.ts",
|
|
@@ -15,10 +19,10 @@
|
|
|
15
19
|
],
|
|
16
20
|
"dependencies": {
|
|
17
21
|
"fastify": "^5.12.1",
|
|
18
|
-
"@basaltkit/core": "^1.1
|
|
19
|
-
"@basaltkit/fastify": "^1.
|
|
20
|
-
"@basaltkit/mailer": "^1.
|
|
21
|
-
"@basaltkit/queue": "^1.
|
|
22
|
+
"@basaltkit/core": "^1.3.1",
|
|
23
|
+
"@basaltkit/fastify": "^1.8.1",
|
|
24
|
+
"@basaltkit/mailer": "^1.4.1",
|
|
25
|
+
"@basaltkit/queue": "^1.4.1"
|
|
22
26
|
},
|
|
23
27
|
"devDependencies": {
|
|
24
28
|
"@types/node": "^26.3.0",
|
|
@@ -27,9 +31,9 @@
|
|
|
27
31
|
"typescript": "^7.0.2",
|
|
28
32
|
"vitest": "^4.1.11",
|
|
29
33
|
"zod": "^3.24.0 || ^4.0.0",
|
|
30
|
-
"@basaltkit/express": "^1.
|
|
31
|
-
"@basaltkit/hono": "^1.
|
|
32
|
-
"@basaltkit/http": "^1.
|
|
34
|
+
"@basaltkit/express": "^1.4.1",
|
|
35
|
+
"@basaltkit/hono": "^1.4.1",
|
|
36
|
+
"@basaltkit/http": "^1.14.0",
|
|
33
37
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
34
38
|
},
|
|
35
39
|
"publishConfig": {
|
|
@@ -48,8 +52,8 @@
|
|
|
48
52
|
"testing"
|
|
49
53
|
],
|
|
50
54
|
"peerDependencies": {
|
|
51
|
-
"@basaltkit/express": "^1.
|
|
52
|
-
"@basaltkit/hono": "^1.
|
|
55
|
+
"@basaltkit/express": "^1.4.1",
|
|
56
|
+
"@basaltkit/hono": "^1.4.1"
|
|
53
57
|
},
|
|
54
58
|
"peerDependenciesMeta": {
|
|
55
59
|
"@basaltkit/express": {
|