@setu-ts/testing

First-party testing utilities for the Setu-TS framework.

Installation

npm install @setu-ts/testing
# Deno: deno add npm:@setu-ts/testing

Usage

createTestApp

Creates a started test application that can be exercised via inject() and fetch() without binding a socket.

import { createMockPlugin, createTestApp } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = await createTestApp({
  plugins: [RuntimePlugin(), createMockPlugin({ name: 'database', service: { query: () => [] } })],
});

app.router.get('/users', (ctx) => ctx.response.json([{ id: 1 }]));
const res = await app.inject({ method: 'GET', url: '/users' });
console.log(res.statusCode); // 200

plugins must include a runtime provider. This package depends only on common and kernel, so it cannot supply RuntimePlugin for you, and the kernel requires the runtime capability at start(). await createTestApp() with no plugins rejects with No plugin provides the mandatory 'runtime' capability.

Global middleware needs autoStart: false. start() compiles the pipeline, after which app.middleware.add(...) throws. Routes are unaffected — app.router.get(...) works on a started app.

Unhandled errors answer in the kernel’s fallback format. createTestApp does not install errorHandler (this package depends only on common and kernel, not exceptions), so an unhandled error returns the kernel’s { error, detail? } fallback body, not RFC 9457.

errorHandler(...) from @setu-ts/exceptions is a middleware, not a plugin — it cannot be passed to plugins (which accepts IPlugin[] and would throw TypeError: plugin.register is not a function at start()). Register it on an un-started app, then start — see the example below the callout.

Registering errorHandler on an un-started application:

import { createTestApp } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';
import { errorHandler } from '@setu-ts/exceptions';

const app = await createTestApp({
  plugins: [RuntimePlugin()],
  autoStart: false,
});

app.middleware.add(errorHandler({ format: 'rfc9457' }), {
  priority: 0,
  name: 'error-handler',
});

await app.start();

Once registered, the responder seam governs the kernel’s own 404/500 terminals too.

The app: arm dissolves this. An application built from the project’s own composition root already carries whatever errorHandler that root registered, so its error bodies are the ones production serves. This package deliberately installs no responder of its own: it depends on common and kernel only, so a default would mean a second RFC 9457 formatter that @setu-ts/exceptions’ own tests do not drive.

Building from the composition root

createTestApp has a second arm. Pass app instead of plugins and the test starts from the application the project actually ships — the createApp() a scaffolded project exports from setu.config.ts, or a starter factory’s return value — then subtracts from it:

New scaffolds return IKernelApplication (or Promise<IKernelApplication>). In an older project, change the factory’s IApplication annotation to IKernelApplication, imported from @setu-ts/kernel, before using the app arm. Await an async starter factory.

import { createTestApp, overrideCapability } from '@setu-ts/testing';
import { CAPABILITIES } from '@setu-ts/common';
import { createApp } from '../setu.config.ts';

const app = await createTestApp({
  app: createApp(),
  without: ['database'],
  overrides: [overrideCapability(CAPABILITIES.MAIL, fakeMailer)],
});

Everything the root registered is present — its middleware, its error handling, its health indicators, its route ordering — except what without explicitly removes: an excluded plugin contributes none of those, which is the point of excluding it. A test therefore observes the composition production has rather than a second one assembled by hand — which is what the plugins arm inevitably drifts into.

The two arms are mutually exclusive: TestAppOptions is a union, so supplying both plugins and app is a compile error rather than a runtime throw.

without and overrides do different things, and the difference matters.

Both are applied before createTestApp calls start(); what differs is when they take effect during that startup.

withoutoverrides
AppliedBefore start()Before start()
Takes effectThe plugin is gone from the listLate in the same startup, after the real plugin registers
EffectThe plugin’s register() never runsThe plugin’s service is replaced
Eager side effects (connect(), a broker dial)PreventedAlready happened

DatabasePlugin.register() calls adapter.connect(), so an override leaves a real connection attempt in place. Use without when you need the plugin not to run at all, and overrides when you want it composed but serving a double.

without throws naming an entry the application does not hold. A silently ignored without: ['databse'] would run the whole test against the real plugin while reporting success.

overrideCapability

Creates a plugin that replaces an already-provided capability, leaving the rest of the composition intact.

import { overrideCapability } from '@setu-ts/testing';
import { CAPABILITIES } from '@setu-ts/common';

const app = createApp();
app.register(overrideCapability(CAPABILITIES.MAIL, { send: () => Promise.resolve() }));
await app.start();

It applies the three constraints that make a replacement plugin work: it declares no provides (a second declaration of a live token is refused before any plugin runs), it registers with { override: true }, and it orders itself through an optionalDependencies edge on the token plus PLUGIN_PRIORITY.HIGHEST — after the provider, whatever its band, and before an ordinary consumer.

It throws during start() when nothing provides the token. Without that check a mistyped token would register the double under a nonsense name, leave the real service serving, and let the test pass against the real dependency.

It is ordered after the provider and before ordinary consumers, so it reaches a consumer that resolves the capability during its own register() as well as one that resolves it per request — NotificationPlugin resolves CAPABILITIES.MAIL while registering, and an override beneath it is seen by every notification it sends. Ordering comes from an optionalDependencies edge on the token plus an early priority, so it works against a provider in any priority band.

Two bounds remain, both documented rather than surprising:

  • It does not undo the provider’s eager side effects. The provider’s register() has run, so a database adapter’s connect() already happened. Use without for that.
  • It requires the provider to declare the token in provides — that is what the ordering edge hangs on, and how a plugin is depended upon at all. A provider that registers a capability without declaring it fails startup with Capability '<token>' is already registered; declare provides, or use the removal form below.

A service provided by the DI container cannot be replaced this way. With DiPlugin registered, DecoratorPlugin puts each @Injectable class into the container rather than the kernel registry, so overrideCapability('pricing-service', fake) throws that nothing provides the token. Use overrideProvider for it.

To remove the provider instead of replacing it — which also prevents its eager side effects — supply the double as a provider ahead of its consumers:

await createTestApp({
  app: createApp(),
  without: ['mail-plugin'],
  overrides: [
    createMockPlugin({
      name: 'mail-plugin',
      provides: CAPABILITIES.MAIL,
      service: fakeMailer,
      priority: PLUGIN_PRIORITY.HIGH, // ahead of the consumer that captures it
    }),
  ],
});

It also refuses a multi-provider capability. Both refusals are raised from an onInit hook rather than from the plugin’s own register(), because the override registers early — at that point a multi-provider capability has not accumulated its providers yet, and a token it does not shadow may still be registered by a later plugin. Either way the application refuses to start. getAll returns the single and multi registrations concatenated, so an override would ADD a provider while every real one kept running and the test would report success. Detection is generic — a post-registration provider count, not a list of known tokens — so the kernel’s five (health-indicator, metric-registration, openapi-schema, decorator-handler, cli-command) and any capability an application registers with { multi: true } are refused alike. Exclude the plugin that registers the provider instead.

overrideProvider

Replaces a provider in the DI container. With DiPlugin registered, DecoratorPlugin puts each @Injectable class into the container rather than the service registry, so overrideCapability cannot reach it. overrideProvider registers the double on the container before DecoratorPlugin registers the real class, and every class that injects the token, a controller included, is constructed with the double:

import { createTestApp, overrideProvider } from '@setu-ts/testing';

const app = await createTestApp({
  app: createApp(),
  overrides: [overrideProvider('pricing-service', { useValue: { price: () => 0 } })],
});

The token is the class’s @Injectable({ token }) value, or the one the decorator derived from its name. The provider is any container provider: { useValue }, { useFactory } or { useClass }. It depends on CAPABILITIES.DI_CONTAINER, so it runs after DiPlugin and before DecoratorPlugin. start() fails when the application has no container, when the real provider was registered before the override, or when nothing in the application registers or injects the token, so a mistyped token fails the test instead of passing against the real class.

overrideCapability replaces, createMockPlugin provides. createMockPlugin declares the token in provides — which is what satisfies a dependent plugin’s dependencies check, and which the kernel refuses when a real plugin already declares it (Capability 'database' is provided by both 'database' and 'database-mock'). Use it in an application that does not register the real plugin.

inject

Free-function HTTP request injector with string, InjectRequest, and web-standard Request shorthand.

import { inject } from '@setu-ts/testing';

// String shorthand (GET only)
const res = await inject(app, '/users');

// POST with JSON body
const res2 = await inject(app, {
  method: 'POST',
  url: '/users',
  body: { name: 'test' },
});

// Web Request
const req = new Request('http://localhost/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'test' }),
});
const res3 = await inject(app, req);

A Request body is a one-shot stream. Injecting one consumes it, so the same Request cannot be injected twice or injected and then handed to app.fetch() — the second call throws and names the cause rather than quietly sending no body. Build a separate Request per call.

For an oversized multipart upload in an in-process test on Node 24 or Node 26 (up to 26.12.0), use inject() with an encoded body (or build the multipart body from a Blob) instead of passing file-bearing FormData to app.fetch(new Request(...)). The undici those releases bundle (7.20.0–8.11.2) throws an uncaught ERR_INVALID_STATE when the runtime cancels that stream at maxBodyBytes; undici 8.12.0 fixes it. Node 22, Bun, Deno, real-socket uploads and inject() do not take that path.

createMockPlugin

Creates an IPlugin that registers a mock service under a capability token.

import { createMockPlugin } from '@setu-ts/testing';

const mockDb = createMockPlugin({
  name: 'database',
  service: { query: () => [], connect: () => {} },
});

createTestContext

Builds a contract-faithful IRequestContext for unit-testing middleware and handlers in isolation.

import { createTestContext } from '@setu-ts/testing';

const ctx = createTestContext();
expect(ctx.id).toBe('test-ctx');
expect(ctx.startTime).toBe(0); // monotonic, never Date.now()

FixtureManager

Collects mocks and plugins, produces IPlugin[], resets between tests.

import { createTestApp, FixtureManager } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

const fixtures = new FixtureManager();

beforeEach(async () => {
  fixtures.mock('database', { query: () => [] }).mock('cache', { get: () => null });

  const app = await createTestApp({
    plugins: [RuntimePlugin(), ...fixtures.plugins()],
  });
});

afterEach(() => fixtures.reset());

Configuration Options

createTestApp(options?: TestAppOptions):

A union of two mutually exclusive arms.

TestAppFromPlugins — assemble by hand:

OptionTypeDefaultBehavior
pluginsIPlugin[][]Pre-registered before start(). Must include a runtime capability provider.
autoStartbooleantruefalse returns the un-started app, needed to add plugins or global middleware.

TestAppFromApp — start from the composition root:

OptionTypeDefaultBehavior
appIKernelApplicationrequiredAn already-constructed, not-yet-started application.
withoutreadonly string[][]Plugin names dropped before start(), so their register() never runs. Throws on a name the app does not hold.
overridesreadonly IPlugin[][]Plugins appended after without — usually overrideCapability results.
autoStartbooleantrueAs above.

overrideCapability(token: CapabilityToken, service: object) takes no options; it throws at register() when nothing provides token.

createMockPlugin(options: MockPluginOptions):

OptionTypeDefaultBehavior
namestringrequiredPlugin name, and the token when provides is omitted.
serviceobjectrequiredThe mock service registered under the token.
providesstringnameCapability token override.
prioritynumberomittedRegistration priority.
register(ctx: IPluginContext) => void | Promise<void>omittedExtra registration after the service.

createTestContext(options?: TestContextOptions) accepts request, body, runtime, startTime, services, response, params, query, state and signal. startTime takes precedence over runtime.hrtime() and must be a monotonic reading — never Date.now(). signal precedence is request.signal > signal > a live never-aborting signal, matching the kernel.

See Testing Package in PUBLIC_API.md for the full option tables and notes.

API Reference

Exports

ExportKind
collectStreamfunction
createMockPluginfunction
createTestAppfunction
createTestContextfunction
injectfunction
overrideCapabilityfunction
overrideProviderfunction
FixtureManagerclass
MockResponseclass
MockServiceRegistryclass
IKernelApplicationinterface
InjectRequestinterface
InjectResponseinterface
MockPluginOptionsinterface
StreamingBodyinterface
TestAppFromAppinterface
TestAppFromPluginsinterface
TestContextOptionsinterface
TestAppOptionstype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

API reference

Testing Package (@setu-ts/testing)

First-party testing utilities for the Setu-TS framework: a test application factory, mock plugin builder, request injector, mock request context, service registry double, fixture manager, and streaming-response reader.

Exports

ExportFileDescription
createTestAppsrc/test-app.tsTest application factory
TestAppOptionssrc/test-app.tsFactory options (union of the two arms below)
TestAppFromPluginssrc/test-app.tsHand-assembled arm
TestAppFromAppsrc/test-app.tsComposition-root arm
overrideCapabilitysrc/override-capability.tsCapability replacement plugin builder
overrideProvidersrc/override-provider.tsDI container provider replacement builder
createMockPluginsrc/mock-plugin.tsMock plugin builder
MockPluginOptionssrc/mock-plugin.tsBuilder options
collectStreamsrc/inject.tsCollect streaming response body
injectsrc/inject.tsInject HTTP requests into test app
StreamingBodysrc/inject.tsStream collector result shape
createTestContextsrc/mock-context.tsCreate a mock IRequestContext
TestContextOptionssrc/mock-context.tsContext builder options
MockResponsesrc/mock-context.tsFake IResponse double
MockServiceRegistrysrc/mock-registry.tsFake IServiceRegistry double
FixtureManagersrc/fixtures/fixture-manager.tsAssemble mock plugins per-test
IKernelApplication(re-export from @setu-ts/kernel)Kernel application interface
InjectRequest(re-export from @setu-ts/kernel)Shape for inject() request
InjectResponse(re-export from @setu-ts/kernel)Shape for inject() response

Registration

import { createTestApp } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DatabasePlugin } from '@setu-ts/database-plugin';

const app = await createTestApp({
  plugins: [RuntimePlugin(), DatabasePlugin({ type: 'memory' })],
});

Or from the application the project already composes, which keeps the test’s composition — middleware, error handling, health indicators, route ordering — the one production has:

import { createTestApp, overrideCapability } from '@setu-ts/testing';
import { CAPABILITIES } from '@setu-ts/common';
import { createApp } from '../setu.config.ts';

const app = await createTestApp({
  app: createApp(),
  without: ['database'],
  overrides: [overrideCapability(CAPABILITIES.MAIL, fakeMailer)],
});

Options

TestAppOptions — createTestApp(options?) — is a union of two mutually exclusive arms, so supplying both plugins and app is a compile error rather than a runtime throw.

New scaffold factories return IKernelApplication (or Promise<IKernelApplication>). For an older project, replace the factory’s IApplication annotation with IKernelApplication, imported from @setu-ts/kernel. Await an async factory before passing its result to createTestApp.

TestAppFromPlugins — assemble by hand (unit scope):

OptionTypeDefaultBehavior
pluginsIPlugin[][]Pre-registered before start(). Must include a runtime capability provider (RuntimePlugin(), or a mock providing CAPABILITIES.RUNTIME) whenever autoStart is left true — the kernel requires one and throws otherwise.
autoStartbooleantruetrue calls await app.start() (no port, so no socket) before returning. false returns the un-started app — required to register further plugins or to add global middleware.

TestAppFromApp — start from the composition root:

OptionTypeDefaultBehavior
appIKernelApplicationrequiredAn already-constructed, not yet started application — typically a scaffolded project’s createApp() from setu.config.ts, or a starter factory’s return value.
withoutreadonly string[][]Plugin names dropped via IKernelApplication.unregister before start(), so each plugin’s register() — and any eager side effect in it — never runs. Throws naming an entry the app does not hold.
overridesreadonly IPlugin[][]Plugins appended after without is applied, usually overrideCapability results. Exists because the default autoStart: true leaves no window in which a caller could register them.
autoStartbooleantrueAs above.

without and overrides are not interchangeable. An override replaces a service after the real plugin’s register() has run, so an eager side effect inside it — DatabasePlugin calls adapter.connect() there — has already happened. Only without prevents that.

overrideCapability(token: CapabilityToken, service: object): IPlugin takes no options. The plugin it returns declares no provides (a second declaration of a live token is refused by the kernel before any plugin runs), registers with { override: true }, and orders itself through an optionalDependencies edge on the token plus PLUGIN_PRIORITY.HIGHEST — after the provider, whatever its band, and before an ordinary consumer. It throws during start() when nothing provides token — a mistyped token would otherwise register the double under a nonsense name and leave the real service serving — and when token is a multi-provider capability. Both checks are raised from an onInit hook rather than from the plugin’s own register(), because the override registers early: at that point a multi-provider capability has not accumulated its providers yet, and a token the override does not shadow may still be registered by a later plugin. onInit runs once every plugin has registered, and a throw there fails start() exactly as one in register() would. ServiceRegistry.getAll returns the single and multi registrations concatenated, so an override of one ADDS a provider rather than replacing the existing ones: every real provider would still run while the caller was told the capability was overridden. Detection is generic — a provider count taken after the write, not a list of known tokens — so the kernel’s five (health-indicator, metric-registration, openapi-schema, decorator-handler, cli-command) and any capability an application registers itself with { multi: true } are refused alike. The count resolves nothing that is being replaced: the override lands in the single map first, so a registerFactory provider is never constructed. Exclude the plugin that registers the provider instead.

overrideCapability is ordered after the provider and before ordinary consumers, so it reaches a consumer that resolves the capability during its own register() as well as one that resolves it per request — NotificationPlugin resolves CAPABILITIES.MAIL while registering, and an override beneath it is seen by every notification it sends. Ordering comes from an optionalDependencies edge on the token, which the resolver visits ahead of any priority number, plus an early priority that places it before a PLUGIN_PRIORITY.NORMAL consumer — so a provider in any band is replaced.

Two bounds remain. It does not undo the provider’s eager side effects: the provider’s register() has run, so a database adapter’s connect() already happened — without is what prevents that. And it requires the provider to declare the token in provides, which is what the ordering edge hangs on and how a plugin is depended upon at all; a provider that registers a capability without declaring it fails startup with Capability '<token>' is already registered. For either, exclude the provider and supply the double ahead of its consumers — without: ['mail-plugin'] plus a createMockPlugin at PLUGIN_PRIORITY.HIGH.

overrideProvider<T>(token: ContainerToken<T>, provider: Provider<T>, options?: ProviderOptions<T>): IPlugin replaces a provider in the DI container, which overrideCapability cannot reach: with DiPlugin registered, DecoratorPlugin puts each @Injectable class into the container, and the container refuses a second registration of a token. The plugin depends on CAPABILITIES.DI_CONTAINER and registers at PLUGIN_PRIORITY.NORMAL, so it runs after DiPlugin and before DecoratorPlugin (PLUGIN_PRIORITY.LOW). It registers the double on the container, then publishes a container that swallows the application’s later registration of the same token and passes every other call through, so every class that injects the token is constructed with the double. start() fails from register() when the application has no container or the real provider was registered first, and from an onInit hook when nothing registered, resolved or checked for the token, which is how a mistyped token surfaces. The token may be a string, a createToken token or a class (M125); the double also applies inside request scopes (forRequest), and dispose() passes through to the real container.

overrideCapability replaces; createMockPlugin provides. createMockPlugin declares the token in provides, which satisfies a dependent plugin’s dependencies check and which the kernel refuses when a real plugin already declares it. Use it in an application that does not register the real plugin.

Unhandled errors. Neither arm installs errorHandler — this package depends on common and kernel only, so a default would mean a second RFC 9457 formatter that @setu-ts/exceptions’ own tests do not drive. The plugins arm therefore answers the kernel’s { error, detail? } fallback; the app: arm inherits whatever responder the composition root registered, and is the answer for response-shape fidelity.

MockPluginOptions — createMockPlugin(options):

OptionTypeDefaultBehavior
namestringrequiredPlugin name, and the capability token when provides is omitted.
serviceobjectrequiredThe mock service registered under the token.
providesstringnameOverrides the token when the plugin name and capability token differ.
prioritynumberomittedRegistration priority passed to the kernel resolver.
register(ctx: IPluginContext) => void | Promise<void>omittedExtra registration (middleware, routes, hooks) run after the service is registered.

TestContextOptions — createTestContext(options?):

OptionTypeDefaultBehavior
requestPartial<IRequest>GET http://localhost/Overrides on the mock request (method, url, path, headers, ip, user, tenant, signal).
bodyunknownnoneBacks json(), text() and bytes(). A non-string body is JSON-stringified once, so all three readers agree.
runtimeIRuntimeServicesinternal monotonic fakeSupplies ctx.id (uuid()) and ctx.startTime (hrtime()).
startTimenumberruntime.hrtime() (0)Highest-precedence monotonic origin — never pass Date.now().
servicesIServiceRegistrynew MockServiceRegistry()Any implementation, including a real kernel registry from a started app.
responseIResponsenew MockResponse()The response builder on ctx.response.
paramsRecord<string, string>{}Path parameters.
queryRecord<string, string>parsed from the request URLQuery parameters.
stateMap<string, unknown>new Map()Request-scoped state.
signalAbortSignallive, never-abortingctx.signal. Precedence: request.signal > signal > default, matching the kernel.

Programmatic Testing

import { collectStream, createMockPlugin, createTestContext, inject } from '@setu-ts/testing';
import { FixtureManager, MockServiceRegistry } from '@setu-ts/testing';

// Mock a plugin service
const mockDb = createMockPlugin({
  name: 'database',
  service: { find: async () => [] },
});

// Use FixtureManager to assemble mocks
const fixtures = new FixtureManager();
fixtures.mock('cache', { get: async () => null, set: async () => {} });

// Inject a request directly
const response = await inject(app, {
  method: 'GET',
  url: '/users',
});

// Create a fake context for handler-level tests
const ctx = createTestContext();
const mockRegistry = new MockServiceRegistry();

// Register a streaming route so collectStream has data to read
app.router.get('/stream', (c) => {
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(new TextEncoder().encode('hello'));
      controller.enqueue(new TextEncoder().encode(' world'));
      controller.close();
    },
  });
  return c.response.stream(stream);
});

// Collect a streaming body — use fetch() which returns a real web Response
const fetchRes = await app.fetch(new Request('http://localhost/stream'));
const stream = await collectStream(fetchRes);
console.log(stream.text); // → "hello world"

Notes

  • The testing package does not start an HTTP server; createTestApp returns a started kernel application that can be exercised via inject() or fetch().
  • plugins must include a runtime provider. The package depends only on common and kernel, so it cannot import RuntimePlugin to supply one for you, and the kernel treats the runtime capability as mandatory at start(). await createTestApp() with no plugins therefore rejects with No plugin provides the mandatory 'runtime' capability. Pass RuntimePlugin(), or for a dependency-free unit test createMockPlugin({ name: 'runtime', service: fakeRuntime, provides: CAPABILITIES.RUNTIME }).
  • Adding global middleware requires autoStart: false. start() compiles the pipeline, after which app.middleware.add(...) throws Cannot add middleware after the pipeline has been compiled. Routes are unaffected — app.router.get(...) works on a started app.
  • A mock providing only runtime cannot serve fetch(). The real RuntimePlugin also provides http-adapter; without it app.fetch() throws No HTTP adapter registered. inject() needs only runtime, so unit tests use inject() and fetch() assertions belong to integration/e2e tests on the real RuntimePlugin().
  • The free-function inject() accepts a web Request, and reading its body consumes it. The same Request cannot be injected twice, nor injected and then passed to app.fetch() — the second call throws and names the cause instead of silently sending no body. Build a separate Request per call.
  • inject() returns body: string | null, where a byte body from response.send(bytes) is UTF-8 decoded and null means the response genuinely had no body. A streaming response cannot be rendered as a string without draining the live stream, so inject() throws for one — read those through app.fetch() plus collectStream instead.
  • createTestContext uses a built-in monotonic fake runtime (hrtime: () => 0) by default — inject your own IRuntimeServices when you need controllable timing. Its timers are inert no-ops, so a fixture can never leak a callback past the test that created it.
  • MockServiceRegistry and MockResponse reproduce their kernel counterparts’ observable semantics (factory caching, single-vs-multi registration and getAll merge order, override policy, the verbatim not-registered message, content-type defaults, and the snapshot() discriminated union) so a test cannot pass against the double and fail against the real thing.

Agent card · TypeDoc API reference