npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@znight/testing

v0.6.1

Published

Znight testing utilities: TestBed with auto-mocking and TestApp for end-to-end tests

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 started Application without 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/testing

Runs 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 as undefined. await mock, JSON.stringify(mock) ({}), String(mock) and equality matchers behave.
  • Object.prototype members (toString, hasOwnProperty...) are the real ones.
  • Assigned properties win: mock.enabled = false. With a class, getters read as undefined (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:

  1. Vitest: globalThis.vi.fn (Vitest with globals: true);
  2. Jest: globalThis.jest.fn;
  3. bun:test: mock (loaded only when running on Bun). Bun's expect(...).toHaveBeenCalled() only accepts genuine Bun mocks: they are used as is (deep members are added without wrapping them in a proxy);
  4. 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's expect (_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 too

setDefaultMockFn() 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).