@samveys/testing
From packages/testing/README.md in the framework repository.
Test helpers for Samveys apps and plugins, built on node:test: createTestApp, an in-process HTTP test client, fake time, an events fake, hook recording, and log capture.
Fakes live in the packages they fake: @samveys/mail (fake() transport with assertSent), @samveys/queue (fake() with assertDispatched), @samveys/http-client (client.fake() and preventStrayRequests()). Real database engines for tests come from @samveys/database/conformance (startEngine(), docker CLI). Per-test database transactions are not provided yet.
1. Install
Inside this monorepo, add it as a dev dependency of the package that runs the tests:
{ "devDependencies": { "@samveys/testing": "workspace:*" } }@samveys/core is a peer dependency. @samveys/http is an optional peer: only testApp.http and testApp.kernel() need it, and it is loaded on first use.
2. createTestApp
createTestApp(options) creates an app with test defaults, runs register → boot → start → ready, and returns a handle. Use await using so the app shuts down when the test ends.
| Default | Value |
|---|---|
name | test-app |
environment | test |
debug | false |
processEnv | {} (the real environment is never read) |
| Logger | captured in testApp.logs at level debug |
Every other createApp option (plugins, providers, config, env, shutdownTimeout, …) is passed through, and caller values override the defaults.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { definePlugin } from '@samveys/core'
import { createTestApp } from '@samveys/testing'
const greeter = definePlugin({
name: '@acme/greeter',
boot({ hooks, logger }) {
hooks.on('app:ready', () => logger.info('greeter ready'))
},
})
test('the plugin installs and logs when the app is ready', async () => {
await using testApp = await createTestApp({ plugins: [greeter()] })
assert.equal(testApp.app.state, 'ready')
assert.equal(testApp.hooks.count('app:ready'), 1)
assert.equal(testApp.logs.find('info', 'greeter ready').length, 1)
})| Member | What it does |
|---|---|
app | The Application |
logs | Captured log lines: records, find(level, text?), clear() |
hooks | Every on emission since the app was created: calls(name), count(name), clear(). Emissions through route-scoped emitters are included |
events | Records app.events: emitted(event), assertEmitted(event, match?), assertNotEmitted(event) |
http | In-process HTTP client (see §3) |
kernel() | The HTTP kernel behind http, created once |
swap(token, factory) | container.swap() that close() undoes |
emit(name, ...args) | Fire a hook as the framework would |
close() | Shut down; returns the shutdown result. Idempotent, and run by await using |
start: false stops after register(), for tests of register-time behaviour. If startup throws, the app is shut down and the original error is rethrown.
3. HTTP test client
testApp.http sends requests straight to kernel.fetch; no socket is opened. Add routes with routes, or install the http() plugin: its routes and server config are reused.
import { test } from 'node:test'
import type { HttpContext } from '@samveys/core'
import { schema } from '@samveys/validator'
import { createTestApp } from '@samveys/testing'
const CreateUser = schema.object({ name: schema.string().min(2), email: schema.string().email() })
test('POST /users validates the body', async () => {
await using testApp = await createTestApp({
routes: (router) => {
router.post('/users', { body: CreateUser }, ({ body, response }) => response.created({ name: body.name }))
router.get('/users/:id', (ctx: HttpContext) => ({ id: ctx.params.id }))
},
})
;(await testApp.http.post('/users', { json: { name: 'Ana', email: 'ana@example.com' } }))
.assertStatus(201)
.assertJson({ name: 'Ana' })
;(await testApp.http.post('/users', { json: { name: 'A' } })).assertProblem(422, 'SVS_E042')
;(await testApp.http.get('/missing')).assertProblem(404, 'SVS_E040')
})| Request option | Meaning |
|---|---|
json | JSON body with content-type: application/json |
form | application/x-www-form-urlencoded body |
body | Raw body; set content-type yourself |
query | Appended to the URL; arrays repeat the key |
headers, signal | As in fetch |
Only one of json, form, and body may be set. withHeaders(headers) returns a client that adds those headers to every request, for example an auth token.
Each TestResponse has status, headers, text, json(), and the raw Response. The assertions return the response, so they chain, and their failure messages include the method, path, status, and the start of the body:
| Assertion | Passes when |
|---|---|
assertStatus(status) | The status matches |
assertProblem(status, code?) | content-type is application/problem+json, and status and code match |
assertHeader(name, value?) | The header is present; equal to a string; or matching a RegExp |
assertJson(expected) | Every key in expected matches the body, recursively; arrays must have the same length |
createTestClient(fetch) builds the same client around any (request) => Promise<Response> function.
4. Fake time
fakeTime() drives node:test mock.timers. Pass t.mock to tie it to one test, and use using so the real timers come back.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { fakeTime } from '@samveys/testing'
test('a token expires after 15 minutes', (t) => {
using time = fakeTime({ mock: t.mock })
let expired = false
setTimeout(() => (expired = true), 15 * 60_000)
time.advance('14m')
assert.equal(expired, false)
time.advance('1m')
assert.equal(expired, true)
assert.equal(time.now().toISOString(), '2026-01-01T00:15:00.000Z')
})| Method | What it does |
|---|---|
now() | Current fake time. It starts at 2026-01-01T00:00:00.000Z unless now is given |
advance(by) | Milliseconds or '500ms', '30s', '15m', '2h', '1d'; runs the timers that fall due |
set(to) | Jumps without running timers in between |
runAll() | Runs every pending timer |
restore() | Brings back the real timers and clock; idempotent |
mock.timers.runAll() in Node cannot run a timer that set() has already jumped past, so schedule such timers after the jump.
5. Recorders without an app
recordHooks(registry), fakeEvents(emitter, { swallow? }), and captureLogs(level?) work on any hook registry, event emitter, or logger. Each recorder has stop(), which puts the original emit back.
6. Reference
| Export | Kind | Purpose |
|---|---|---|
createTestApp | function | Boot an app for a test (§2) |
createTestClient | function | HTTP test client around a fetch function (§3) |
fakeTime | function | Fake timers and clock (§4) |
recordHooks | function | Record hook emissions (§5) |
fakeEvents | function | Record and assert event emissions (§5) |
captureLogs | function | Capture JSON log lines (§5) |
TestApp, TestAppOptions, TestClient, TestResponse, TestRequestOptions, FakeTime, FakeTimeOptions, HookRecorder, EventsFake, LogCapture, LogRecord | types | From contracts.ts |