@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.
Files changed (2) hide show
  1. package/README.md +97 -14
  2. 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 injects HTTP requests directly into the Fastify server, without the network — and lets you "pretend" the request comes from a specific user or tenant (`actingAs` / `asTenant`), without going through login. 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.
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
- The response is a Fastify `LightMyRequestResponse`: use `.statusCode`, `.json()`, `.body`, `.headers`.
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` automatically adds a test plugin that reads the special `x-test-user` / `x-test-tenant` headers and populates `ctx().user` / `ctx().tenant` — the same context your application uses in production. **Never register this mechanism in a real application.**
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
- | Parameter | Type | Required? | Default | Description |
211
- | --- | --- | --- | --- | --- |
212
- | `options` | `CreateAppOptions` | No | `{}` | Options for `createApp`; your `plugins` are added after the impersonation plugin |
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>` | Shuts down the application (call at the end of every test) |
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
- - `MailAssertionError` code `TEST_MAIL_ASSERTION` (extends `BasaltError`).
288
- - `QueueAssertionError` — code `TEST_QUEUE_ASSERTION` (extends `BasaltError`).
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 works through `@basaltkit/fastify` request enrichers; it needs `fastifyPlugin` registered.
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`** — requests are injected into the `FastifyInstance` (token `FASTIFY`); impersonation is a `RequestEnricher` registered in the `http:enrichers` bucket.
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.0",
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.2",
19
- "@basaltkit/fastify": "^1.6.1",
20
- "@basaltkit/mailer": "^1.2.2",
21
- "@basaltkit/queue": "^1.2.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.2.1",
31
- "@basaltkit/hono": "^1.2.1",
32
- "@basaltkit/http": "^1.9.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.2.1",
52
- "@basaltkit/hono": "^1.2.1"
55
+ "@basaltkit/express": "^1.4.1",
56
+ "@basaltkit/hono": "^1.4.1"
53
57
  },
54
58
  "peerDependenciesMeta": {
55
59
  "@basaltkit/express": {