@setu-ts/testing
v0.11.0
Published
Test app factory, mock plugins, and request injection for Setu-TS
Readme
@setu-ts/testing
First-party testing utilities for the Setu-TS framework.
Installation
npm install @setu-ts/testing
# Deno: deno add npm:@setu-ts/testingUsage
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
pluginsmust include a runtime provider. This package depends only oncommonandkernel, so it cannot supplyRuntimePluginfor you, and the kernel requires theruntimecapability atstart().await createTestApp()with no plugins rejects withNo plugin provides the mandatory 'runtime' capability.Global middleware needs
autoStart: false.start()compiles the pipeline, after whichapp.middleware.add(...)throws. Routes are unaffected —app.router.get(...)works on a started app.Unhandled errors answer in the kernel's fallback format.
createTestAppdoes not installerrorHandler(this package depends only oncommonandkernel, notexceptions), so an unhandled error returns the kernel's{ error, detail? }fallback body, not RFC 9457.
errorHandler(...)from@setu-ts/exceptionsis a middleware, not a plugin — it cannot be passed toplugins(which acceptsIPlugin[]and would throwTypeError: plugin.register is not a functionatstart()). 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 whatevererrorHandlerthat root registered, so its error bodies are the ones production serves. This package deliberately installs no responder of its own: it depends oncommonandkernelonly, 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.
| | without | overrides |
| ----------------------------------------------- | ------------------------------------ | --------------------------------------------------------- |
| Applied | Before start() | Before start() |
| Takes effect | The plugin is gone from the list | Late in the same startup, after the real plugin registers |
| Effect | The plugin's register() never runs | The plugin's service is replaced |
| Eager side effects (connect(), a broker dial) | Prevented | Already 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'sconnect()already happened. Usewithoutfor 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 withCapability '<token>' is already registered; declareprovides, 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.
overrideCapabilityreplaces,createMockPluginprovides.createMockPlugindeclares the token inprovides— which is what satisfies a dependent plugin'sdependenciescheck, 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
Requestbody is a one-shot stream. Injecting one consumes it, so the sameRequestcannot be injected twice or injected and then handed toapp.fetch()— the second call throws and names the cause rather than quietly sending no body. Build a separateRequestper 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:
| Option | Type | Default | Behavior |
| ----------- | ----------- | ------- | ------------------------------------------------------------------------------- |
| plugins | IPlugin[] | [] | Pre-registered before start(). Must include a runtime capability provider. |
| autoStart | boolean | true | false returns the un-started app, needed to add plugins or global middleware. |
TestAppFromApp — start from the composition root:
| Option | Type | Default | Behavior |
| ----------- | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| app | IKernelApplication | required | An already-constructed, not-yet-started application. |
| without | readonly string[] | [] | Plugin names dropped before start(), so their register() never runs. Throws on a name the app does not hold. |
| overrides | readonly IPlugin[] | [] | Plugins appended after without — usually overrideCapability results. |
| autoStart | boolean | true | As above. |
overrideCapability(token: CapabilityToken, service: object) takes no options; it throws at
register() when nothing provides token.
createMockPlugin(options: MockPluginOptions):
| Option | Type | Default | Behavior |
| ---------- | ------------------------------------------------ | -------- | ------------------------------------------------------ |
| name | string | required | Plugin name, and the token when provides is omitted. |
| service | object | required | The mock service registered under the token. |
| provides | string | name | Capability token override. |
| priority | number | omitted | Registration priority. |
| register | (ctx: IPluginContext) => void \| Promise<void> | omitted | Extra 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
createTestApp— Test application factorycreateMockPlugin— Mock plugin builderoverrideCapability— Capability replacement plugin builderoverrideProvider— DI container provider replacement plugin builderinject— Free-function request injectorcreateTestContext— Mock request context builderMockServiceRegistry/MockResponse— Kernel-faithful doublesFixtureManager— Multi-mock fixture managercollectStream— Streaming response reader
Exports
| Export | Kind |
| --------------------- | --------- |
| collectStream | function |
| createMockPlugin | function |
| createTestApp | function |
| createTestContext | function |
| inject | function |
| overrideCapability | function |
| overrideProvider | function |
| FixtureManager | class |
| MockResponse | class |
| MockServiceRegistry | class |
| IKernelApplication | interface |
| InjectRequest | interface |
| InjectResponse | interface |
| MockPluginOptions | interface |
| StreamingBody | interface |
| TestAppFromApp | interface |
| TestAppFromPlugins | interface |
| TestContextOptions | interface |
| TestAppOptions | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
