@znight/testing
v0.6.1
Published
Znight testing utilities: TestBed with auto-mocking and TestApp for end-to-end tests
Maintainers
Readme
@znight/testing
Testing utilities for Znight applications: unit tests with auto-mocked dependencies, end-to-end tests with in-memory requests.
TestBed: unit tests. Builds one provider with its real (generated) factory and auto-mocks its direct dependencies.TestApp: end-to-end tests. A startedApplicationwithout signal handlers, with overrides applied last, only what the tested entry points need instantiated, and in-memory HTTP requests (app.inject()).- Deep auto-mocks and a Jest-compatible spy, cooperating with
bun:test, Vitest and Jest.
# bun
bun add -d @znight/testing
# npm
npm install -D @znight/testingRuns on Node.js >= 24 and Bun >= 1.3 (ESM only), with any test runner.
Documentation: @znight/testing
and the testing guide.
Examples: every example application
ships its tests (bun test): unit tests with TestBed, HTTP tests with TestApp in
rest-api, message consumers in
worker, event streams and a
virtual clock in uptime-monitor.
Generated metadata first
Znight resolves dependencies at build time: TestBed and TestApp read the metadata generated by
@znight/compiler (__ZNIGHT_METADATA__, src/.znight/container.generated.ts). It is not committed,
so make sure it is fresh before the tests run:
| Test runner | Setup |
| ----------- | ----------------------------------------------------------------------------------------------- |
| bun test | Preload the compiler: bunfig.toml → [test] preload = ["@znight/compiler/bun"] |
| Vitest | Add the znight() Vite plugin to vitest.config.ts (it regenerates the metadata on every run) |
| Anything | Run znight generate before the tests ("pretest": "znight generate"), --check fails in CI |
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'Without metadata, or for a class missing from it, TestBed reports a ZNIGHT001 naming the class
and how to regenerate the metadata. A metadata format mismatch is a ZNIGHT018. Stale metadata (a
constructor changed since the last generation) calls the factory with outdated dependencies: keep
the preload / plugin / pretest script in place.
TestBed
import { describe, expect, test } from 'bun:test'
import { TestBed } from '@znight/testing'
import { __ZNIGHT_METADATA__ as metadata } from '../src/.znight/container.generated.js'
describe('UserService', () => {
test('finds a user', async () => {
const { instance, mocks } = await TestBed.create(UserService, { metadata })
mocks.get(UserRepository).findById.mockResolvedValue({ id: '1', name: 'Ada' })
expect(await instance.findById('1')).toEqual({ id: '1', name: 'Ada' })
expect(mocks.get(UserRepository).findById).toHaveBeenCalledWith('1')
expect(mocks.get(Logger).info).toHaveBeenCalled()
})
})TestBed.create(target, options) builds target (a class, a token, an alias such as an interface
or abstract base, or a unique display name) with its generated factory, then returns:
| Field | Description |
| ----------- | --------------------------------------------------------------------------------------------------- |
| instance | The target, built with real code. Async factories are awaited. |
| mocks | The auto-mocks: get(key), has(key), tokens(), clearAll(), resetAll(). |
| container | A Container holding the target and everything injected into it (for code resolving dependencies). |
Lifecycle hooks (onStart...) are not called: call them yourself when the test needs them.
What gets injected
Only direct dependencies are replaced, by kind:
| Dependency | Injected |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Class, interface, abstract class, @Provides value, external | A deep auto-mock (mocks.get(...)) |
| Configuration class (@Config) | A real instance: field initializers, then @Default values (converted to the field type, like at runtime), then config values |
| Qualified value without class (@Inject('dbUrl'), primitives) | The provide value, otherwise a ZNIGHT001 error naming it |
| Optional (@Optional(), T \| undefined) | undefined, unless provided |
| Multi (T[]) | [], plus the members that are provided |
| Lazy<T> | { get: () => mock } (the same mock as mocks.get(T)) |
| Interceptor (@Intercept, @Logged...) | Nothing, and interceptors are not applied (see aop) |
A dependency used several times (e.g. directly and lazily) always receives the same value.
Qualified class providers (@Named('replica') class Replica) are auto-mocked: their type is known.
Options
| Option | Type | Description |
| ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| metadata | ZnightMetadata | Required. The generated metadata. |
| provide | Map / [key, value][] / record | Values injected instead of auto-mocks. Keys: class, token, alias or display name ({ 'AppFactory.dbUrl': 'postgres://' }). |
| overrides | same as provide | Like provide (and wins over it), but a provider definition { scope, deps, factory } is built, its own dependencies auto-mocked. |
| config | record / [ConfigClass, values][] | Values of configuration classes, keyed by class, token, display name or @Config prefix. |
| aop | 'none' | 'real' | 'real' builds the interceptors (their dependencies auto-mocked, or taken from provide/overrides) and applies them. Default 'none'. |
| deep | boolean | Unconfigured methods of the auto-mocks return deep mocks instead of undefined. Default false. |
| mockFn | 'auto' | 'builtin' | vi.fn... | Spy implementation. Default: auto-detected (see Spies). |
const { instance } = await TestBed.create(UserService, {
metadata,
provide: { 'DbFactory.databaseUrl': 'postgres://test' }, // qualified primitive
config: { database: { host: 'db.test', port: 5433 } }, // @Config('database')
overrides: [
// build the real repository; its own dependencies (Database...) are auto-mocked
[UserRepository, metadata.providers['src/users/pg-user.repository.ts#PgUserRepository']],
],
aop: 'real', // apply @Logged / @Intercept with real interceptors
})Errors are explicit: an unknown key in provide/overrides (ZNIGHT001), an unknown configuration
class or property (ZNIGHT012), an ambiguous display name (ZNIGHT008), a failing factory
(ZNIGHT015 with the original cause and the source location).
mocks
mocks.get(key) accepts a class (typed result: Mocked<T>), a token (TypedToken<T> gives a typed
result), an alias (UserRepository for PgUserRepository) or a display name ('Logger', untyped deep
mock unless you pass the type: mocks.get<Logger>('Logger')). When the dependency was not
auto-mocked, the error says why (provided, configuration class, optional, interceptor...).
mocks.clearAll() forgets every recorded call; mocks.resetAll() also forgets programmed behaviours
and restores the initial implementations.
TestApp
import { expect, test } from 'bun:test'
import { HttpPlugin } from '@znight/http'
import { createMock, override, TestApp } from '@znight/testing'
test('GET /users/:id', async () => {
const repository = createMock(UserRepository)
repository.findById.mockResolvedValue({ id: '1', name: 'Ada' })
await using app = await TestApp.create({
metadata,
plugins: [HttpPlugin.configure({ port: 0 })], // any adapter; port 0 = a free port
overrides: [override(UserRepository, repository)],
})
const response = await app.inject({ method: 'GET', url: '/users/1' })
expect(response.status).toBe(200)
expect(response.json()).toEqual({ id: '1', name: 'Ada' })
})TestApp.create(options) runs Application.create() then app.start(), with signal handlers
disabled. Overrides are applied after the plugins registered (highest precedence) and the real
container is used: interceptors, scopes and lifecycle hooks all run.
app.inject() needs no listening server: configure the HTTP plugin with port: 0 (a free port) so
parallel test files never collide, or pass start: false to stop after onBootstrap.
The same goes for every server a plugin starts on a fixed port. Once prom-client is installed,
ObservabilityPlugin from @znight/observability serves /metrics on port 9464 by default: a
second test file, or znight dev, then fails to start. Disable that server in tests, or give it a
free port when a test scrapes it:
await using app = await TestApp.create({
metadata,
plugins: [
ObservabilityPlugin.configure({ logging: { level: 'silent' }, metrics: { server: false } }), // or server: { port: 0 }
HttpPlugin.configure({ port: 0 }),
],
})TestApp never changes the options of the plugins it is given (they are configured instances):
when the start fails because a port is taken (EADDRINUSE anywhere in the cause chain), the error
carries a hint naming these options.
| Option | Type | Description |
| ----------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| metadata | ZnightMetadata | Required. The generated metadata. |
| plugins | Plugin[] | Plugins, as in Application.create. |
| providers | Map / [key, value][] / record | Values for externals (Logger, ConfigSource...), as in Application.create. |
| overrides | Map / [key, value][] / record | Values or provider definitions replacing providers, applied last. Overridden providers are instantiated at boot. |
| 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, GraphQL resolvers with their guards and loaders), or every provider when there is none. |
| start | boolean | Run app.start() (plugins listen/subscribe). Default true; false stops after onBootstrap. |
| logger | CoreLogger | Default: a console logger writing warnings and errors only. |
| shutdown | { timeoutMs?, hookTimeoutMs? } | Shutdown timeouts. |
Keys of providers and overrides may be classes, tokens, aliases or unique display names
('UserRepository'), like in TestBed; other strings are used as tokens (values for tokens
registered at runtime by plugins).
Limiting the boot to the closure of the entry points keeps tests fast and lets them ignore unrelated
providers (and their missing externals). Other providers are still created on first use (synchronous
factories only). The hosts of scheduled jobs (@Cron, @Interval, @Timeout of
@znight/schedule) are entry points even when the test does not run the scheduler, so a test that
only exercises HTTP routes can pass SchedulePlugin.configure({ enabled: false }) and still trigger
a job through the registry. GraphQL resolvers are entry points as well, with their guards and the
loaders their methods inject (metadata.extensions.graphql, see
RFC 0005): a resolver with an
async dependency is created at boot, not on first use.
Providers a plugin resolves while it bootstraps without the metadata making them entry points (the
global guards, interceptors and exceptionMappers of HttpPlugin.configure({...})) are
instantiated at boot too, with their dependencies, when the plugin lists them in the optional
initRoots(context) method of Plugin (@znight/core), as HttpPlugin does (see
Compiler contract). These plugin roots complete the entry points and never
restrict the boot by themselves: without any entry point, every provider is created.
Options computed from the container (HttpPlugin.configure(c => ({...}))) cannot be listed before
the boot: when such a provider (or one of its dependencies) has an async factory, the boot fails
with ZNIGHT015 ("cannot create ... on demand: its factory is async"); add it to only, or pass
only: 'all':
await using app = await TestApp.create({
metadata: __ZNIGHT_METADATA__,
plugins: [HttpPlugin.configure(c => ({ port: 0, prefix: c.get(AppConfig).prefix, guards: [AuthGuard] }))],
only: [UserController, AuthGuard], // AuthGuard needs the async JwksStore
})| Member | Description |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| inject(request) | In-memory request through the HTTP plugin: { method = 'GET', url, headers?, body?, query?, cookies?, signedCookies? }. |
| injectSse(request) | An event stream (Server-Sent Events) read in memory like EventSource (see below). |
| injectStream(request) | In-memory request resolving at the response headers, its body a ReadableStream read as it is produced. |
| connect(path, options?) | In-memory WebSocket connection through WebSocketPlugin (@znight/websocket, see below). |
| get(key) | app.container.get(key) (class, token, alias or display name). |
| close() | Stops the application (idempotent). Also await using app = await TestApp.create(...). |
| app / container | The Application and its Container. |
inject() resolves with { status, headers, body, bytes, cookies, json() } (body decoded as
UTF-8, bytes the raw body, for downloads, cookies the set-cookie headers parsed by name); it
rejects with ZNIGHT001 when the application has no HTTP plugin and with ZNIGHT021 once closed.
The plugin encodes the request body: strings and bytes as is, a FormData as
multipart/form-data (files included), a Blob / File with its type, other values as JSON.
Cookies make round trips: cookies sends values in a cookie header (after any cookie header of
headers), signedCookies sends values the HTTP plugin signs with its current secret first (a valid
@SignedCookie), and each response.cookies[name] (SetCookieInfo: value, raw, expires,
maxAge, domain, path, secure, httpOnly, sameSite, partitioned, priority) can be sent
back as is:
const login = await app.inject({ method: 'POST', url: '/auth/login', body: { email, password } })
expect(login.cookies['session']).toMatchObject({ httpOnly: true, secure: true, sameSite: 'lax' })
const me = await app.inject({ url: '/auth/me', cookies: { session: login.cookies['session']?.value ?? '' } })
const forged = await app.inject({ url: '/auth/me', signedCookies: { session: 'u-1' } })Event streams (@Sse routes, HttpResponse.sse()) never end, so inject() rejects them; read
them with injectSse(). Events are read only when asked for, and leaving the await using block
(or close()) disconnects: the server sees ctx.signal abort.
test('order events', async () => {
await using stream = await app.injectSse({ url: '/orders/o-1/events' })
expect(stream.status).toBe(200)
expect(stream.retry).toBe(5_000)
app.get(OrderHub).publish({ orderId: 'o-1', sequence: 42, type: 'status', status: 'paid' })
const event = await stream.next() // rejects after 2 000 ms by default
expect(event).toMatchObject({ event: 'status', id: '42' })
expect(event.json()).toMatchObject({ status: 'paid' })
const resumed = await stream.reconnect() // the same request plus `last-event-id: 42`, like EventSource
await resumed.close()
})injectSse() resolves with { status, headers, cookies, body, json(), next({ timeoutMs? }),
take(count), comments, lastEventId, retry, done, close(), reconnect() }, an async iterable of
{ event, data, id, json() } messages; body is the whole body when the answer is not an event
stream (a 404, a 204). injectStream() resolves with { status, headers, cookies, body } (cancel
body, or abort signal, to disconnect). Both reject with ZNIGHT001 when the plugin named 'http'
does not implement them (hint: HttpPlugin of @znight/http ≥ 0.1.0, the release that ships
streaming), and with ZNIGHT021 once closed.
import { readFile } from 'node:fs/promises'
test('uploads and downloads', async () => {
const form = new FormData()
form.append('title', 'Me')
form.append('avatar', new File([await readFile('test/fixtures/me.png')], 'me.png', { type: 'image/png' }))
const uploaded = await app.inject({ method: 'POST', url: '/profile/avatar', body: form })
expect(uploaded.status).toBe(201)
const download = await app.inject({ url: '/reports/q1.pdf', headers: { range: 'bytes=0-99' } })
expect(download.status).toBe(206)
expect(download.bytes.byteLength).toBe(100)
})Mocks
import { createMock } from '@znight/testing'
const repository = createMock(UserRepository) // Mocked<UserRepository>, instanceof UserRepository
const logger = createMock<Logger>() // from an interface
const client = createMock<HttpClient>({ baseUrl: 'http://test', get: async () => ({ ok: true }) })
const db = createMock<Database>(undefined, { deep: true }) // db.select().from('users')- Every property read returns a spy, created on first access and then stable. Spies are deep:
mock.pool.query.mockResolvedValue(rows). - Unconfigured methods return
undefined, or with{ deep: true }a deep mock (the same one on every call, so fluent APIs work). - Mocks are not thenable and inert for serialisers:
then,toJSON,asymmetricMatch,$$typeof,nodeType,tagName,__esModule,@@...and every symbol (Symbol.toPrimitive,Symbol.iterator...) read asundefined.await mock,JSON.stringify(mock)({}),String(mock)and equality matchers behave. Object.prototypemembers (toString,hasOwnProperty...) are the real ones.- Assigned properties win:
mock.enabled = false. With a class, getters read asundefined(not a truthy spy) until assigned. - Functions of the partial implementation become spies running that implementation.
| Helper | Description |
| ---------------------------------------- | ------------------------------------------------------------ |
| createMock(Class?, partial?, options?) | Deep auto-mock. Options: name, deep, mockFn. |
| isMock(value) | Whether value is a mock (or one of its spies). |
| clearMock(mock) | mockClear() on every spy of the mock. |
| resetMock(mock) | Back to the initial state (programmed behaviours forgotten). |
Spies
The spy implementation is detected when a mock is created:
- Vitest:
globalThis.vi.fn(Vitest withglobals: true); - Jest:
globalThis.jest.fn; bun:test:mock(loaded only when running on Bun). Bun'sexpect(...).toHaveBeenCalled()only accepts genuine Bun mocks: they are used as is (deep members are added without wrapping them in a proxy);- otherwise the built-in spy,
createSpy(): dependency-free and Jest-compatible (mock.calls,mock.results,mock.instances,mock.contexts,mock.invocationCallOrder,mock.lastCall,mockReturnValue(Once),mockResolvedValue(Once),mockRejectedValue(Once),mockImplementation(Once),mockReturnThis,mockName,mockClear,mockReset,mockRestore). It is recognised by Jest's and Vitest'sexpect(_isMockFunction).
Force one with the mockFn option, or for the whole test suite in a setup file:
// vitest.setup.ts (Vitest without globals)
import { vi } from 'vitest'
import { setDefaultMockFn } from '@znight/testing'
setDefaultMockFn(vi.fn) // vi.clearAllMocks() / vi.resetAllMocks() then apply to Znight mocks toosetDefaultMockFn() without argument restores auto-detection; 'builtin' forces the built-in spy.
Depending on the framework, a spy's own mockReset() may drop the implementation it was created
with (bun:test does, the built-in spy restores it); resetMock() / mocks.resetAll() always restore
it (partial implementations, deep returns).
Hand-written metadata
For tests that do not run the compiler (plugins, libraries, edge cases), write the metadata by hand:
import { createMetadata, defineTestMetadata, testToken } from '@znight/testing'
// class-first: tokens derived from class names (test#UserService), factories = new Class(...deps)
const metadata = defineTestMetadata({
providers: [
PgUserRepository,
{ class: UserService, deps: [PgUserRepository, '@znight/observability#Logger', { lazy: AuditService }] },
{ token: 'src/db.ts#DbFactory.url', factory: () => 'postgres://', name: 'url' },
],
aliases: [[UserRepository, PgUserRepository]],
externals: ['@znight/observability#Logger'],
extensions: { http: { controllers: [{ token: testToken(UserController), basePath: '/users', routes: [] }] } },
})
// exact compiler shape (resolution order computed)
const raw = createMetadata({
'src/clock.ts#Clock': { scope: 'singleton', class: Clock, deps: [], factory: () => new Clock() },
})| TestProvider field | Description |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| class | Provider class (container.get(Class)). |
| token | Default <tokenPrefix>#<ClassName> (tokenPrefix defaults to test). |
| deps | Classes or tokens, { token, optional: true }, { multi: [...] }, { lazy }. |
| factory | Default deps => new Class(...deps). |
| scope, async, interceptors, dispose, name, primary, source | As in ProviderDefinition. |
Overrides helpers
import { override, overrideFactory } from '@znight/testing'
overrides: [
override(Clock, fixedClock), // type-checked against Clock
overrideFactory(UserRepository, ([db]) => new InMemoryUserRepository(db), { deps: ['src/db.ts#Database'] }),
]Compiler contract
@znight/testing exports no helper called by generated code: the compiler never imports it. It
consumes the generated metadata through the documented runtime shapes only (architecture §3 and §9),
without importing any feature package:
| Metadata | Used by |
| -------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| providers[token].{ class, deps, factory, async, interceptors, name, source } | TestBed (target factory, dependency kinds) |
| aliases, externals | key lookups, error messages |
| extensions.config.configs[].{ token, prefix, fields[].{ property, type, default } } | TestBed configuration instances |
| extensions.http.controllers[].{ token, guards, interceptors, routes[].{ guards, interceptors } } | TestApp boot roots |
| extensions.messaging.consumers[].token | TestApp boot roots |
| extensions.schedule.jobs[].token | TestApp boot roots |
| extensions.graphql.types[kind: 'object'].fields[].resolver.{ token, guards, loaders } | TestApp boot roots |
WebSocket connections
app.connect(path, { query?, headers?, protocols?, origin?, cookies? }) runs the real WebSocket
handshake of WebSocketPlugin (@znight/websocket) through the HTTP pipeline, in memory, and
resolves with a TestWsClient: request(event, data?) (the reply's data; rejects with a
WsRequestError { code, message, details }), next(event?) (the oldest server event not taken
yet: frames are buffered from the start), send, sendRaw, nextRaw, frames, close() and
closed. A refused handshake rejects with a WsHandshakeError (status, headers, body,
json()). connectWebSocket(url, { headers?, protocols? }) gives the same client over the network
(the global WebSocket of Node.js and Bun).
import { connectWebSocket, WsHandshakeError } from '@znight/testing'
await using alice = await app.connect('/chat', { protocols: ['chat.v1'] })
expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
await expect(app.connect('/chat', { origin: 'https://evil.example' })).rejects.toBeInstanceOf(WsHandshakeError)
await using remote = await connectWebSocket(`ws://127.0.0.1:${port}/chat`, { protocols: ['chat.v1'] })Runtime contract with @znight/http: the plugin named 'http' exposes
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 }>
(HttpInjector, HttpInjectResult), dispatching in memory. TestApp forwards every request field
as is (signing signedCookies is the plugin's job), returns the plugin's response as is when it has
bytes and cookies, and otherwise derives bytes from body and returns no cookies ({}).
TestApp.injectStream() / injectSse() delegate the same way to the optional
injectStream(request) / injectSse(request) members of that plugin (HttpStreamInjector:
HttpPlugin of @znight/http ≥ 0.1.0), the method defaulting to GET; the response shapes are
InjectStreamResponse and InjectSseResponse (structural copies of the @znight/http types).
Runtime contract with @znight/websocket: the plugin named 'websocket' exposes
connect(path, options?): Promise<TestWsClient> (WebSocketConnector). WsHandshakeError and
WsRequestError match the errors of @znight/websocket structurally (instanceof checks their
name and status / code). TestApp boots the gateways of metadata.extensions.websocket with
their guards.
Runtime contract with every plugin: the optional initRoots(context: { metadata }): readonly ProviderKey[]
member of Plugin (@znight/core; PluginInitRoots names a plugin implementing it) returns the
providers (classes or tokens) the plugin resolves at boot that the metadata does not make entry
points: the classes given in its options, or providers it derives from its own metadata section
(context.metadata). TestApp.create() calls it once, before creating the application, and adds
the keys that designate providers of the metadata to the providers instantiated at boot (other keys
are ignored: the plugin reports them); it rejects with a TypeError when the result is not an
array. HttpPlugin returns the classes of its guards, interceptors and exceptionMappers
options (nothing when the options are a function of the container).
