@samveys/testing

Version
1.0.3Nov 5, 2026
Install
pnpm add @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:

json
{ "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.

DefaultValue
nametest-app
environmenttest
debugfalse
processEnv{} (the real environment is never read)
Loggercaptured in testApp.logs at level debug

Every other createApp option (plugins, providers, config, env, shutdownTimeout, …) is passed through, and caller values override the defaults.

ts
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)
})
MemberWhat it does
appThe Application
logsCaptured log lines: records, find(level, text?), clear()
hooksEvery on emission since the app was created: calls(name), count(name), clear(). Emissions through route-scoped emitters are included
eventsRecords app.events: emitted(event), assertEmitted(event, match?), assertNotEmitted(event)
httpIn-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.

ts
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 optionMeaning
jsonJSON body with content-type: application/json
formapplication/x-www-form-urlencoded body
bodyRaw body; set content-type yourself
queryAppended to the URL; arrays repeat the key
headers, signalAs 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:

AssertionPasses 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.

ts
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')
})
MethodWhat 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

ExportKindPurpose
createTestAppfunctionBoot an app for a test (§2)
createTestClientfunctionHTTP test client around a fetch function (§3)
fakeTimefunctionFake timers and clock (§4)
recordHooksfunctionRecord hook emissions (§5)
fakeEventsfunctionRecord and assert event emissions (§5)
captureLogsfunctionCapture JSON log lines (§5)
TestApp, TestAppOptions, TestClient, TestResponse, TestRequestOptions, FakeTime, FakeTimeOptions, HookRecorder, EventsFake, LogCapture, LogRecordtypesFrom contracts.ts