Skip to content

@forinda/kickjs-testing ​

Test utilities for creating isolated KickJS application instances with DI overrides.

createTestApp ​

Create an Application configured for testing. Resets the DI container, registers modules, applies overrides, and returns the Application and container. Uses a minimal middleware stack — no helmet, cors, compression or morgan.

typescript
async function createTestApp(options: CreateTestAppOptions): Promise<{
  app: Application
  /** A request client through `app.handle` (needs supertest). */
  client: (options?: TestClientOptions) => TestClient
  /** @deprecated Express-only — throws under any other runtime. Use `client()` or `app.handle`. */
  expressApp: express.Express
  container: Container
}>

It is async — await it, or supertest receives a Promise.

Drive app, not expressApp. app.handle is the Application's own Node request listener and follows whichever runtime is configured, so one suite runs against every engine. expressApp is deprecated and throws under Fastify or h3 rather than handing back that engine's instance mistyped as express.Express — which is how a suite ends up silently exercising the wrong runtime.

typescript
import request from 'supertest'
import { createToken } from '@forinda/kickjs'

const { app, container } = await createTestApp({
  modules: [UserModule()],
  overrides: [[USER_REPO, new InMemoryUserRepo()]],
})

const res = await request(app.handle.bind(app)).get('/api/v1/users').expect(200)

client ​

typescript
interface TestClientOptions {
  headers?: Record<string, string> // sent with every request
  bearer?: string // Authorization: Bearer <token>
  basePath?: string // prefixed to every path
  cookies?: boolean // keep cookies between requests (a supertest agent)
}

interface TestClient {
  get(path: string): Test // also post, put, patch, delete, head, options
  as(token: string): TestClient // same client, with Authorization: Bearer <token>
  withHeaders(headers: Record<string, string>): TestClient
}

Each method returns a supertest Test. as() and withHeaders() return a new client and leave the original unchanged; one scoped from a cookies: true client shares its cookie jar. Guide: The request client.

useTestApp ​

From @forinda/kickjs-testing/vitest. Registers the Vitest hooks that build the app before a file's tests and shut it down after.

typescript
function useTestApp(
  options: () => CreateTestAppOptions | Promise<CreateTestAppOptions>,
  settings?: {
    shared?: boolean | string // one app per worker (isolate: false), per name; true = 'default'
    reset?: 'file' | 'test' | false // when onTestReset resets run; default 'file'
    client?: TestClientOptions // defaults for t.client()
  },
): {
  readonly app: Application
  readonly container: Container
  client(options?: TestClientOptions): TestClient
}

onTestReset / resetTestState ​

typescript
function onTestReset(reset: () => unknown): () => void // returns an unregister function
function resetTestState(): Promise<void> // runs every reset in order; all run, errors aggregated

Guide: Large Suites.

Testing the runtime you deploy ​

Pass runtime — the same value given to bootstrap(). Routing, body parsing, status handling and error mapping all live in the runtime seam, so a green Express suite says nothing about them if you ship Fastify.

typescript
import { fastifyRuntime } from '@forinda/kickjs/fastify'

const { app } = await createTestApp({
  modules: [UserModule()],
  runtime: fastifyRuntime(),
})

The default middleware follows the runtime: empty on an engine that parses bodies natively, [express.json()] otherwise. Passing middlewares: [express.json()] explicitly on Fastify bypasses the Application's own native-body guard — the connect parser then consumes the stream before Fastify reads it, and a JSON POST hangs until the test times out.

Overriding a token ​

createToken() returns a frozen object identified by reference, which TypeScript rejects as a computed property key (TS2464). Pass entries or a Map instead:

typescript
const DATABASE = createToken<Database>('app/Db/connection')

await createTestApp({
  modules: [UserModule()],
  overrides: [[DATABASE, fakeDb()]], // or: new Map([[DATABASE, fakeDb()]])
})

Do not reach for [TOKEN.name]

It compiles — name is a string — and does nothing. The container keys tokens by reference, so the override is accepted and never applied, and the test passes against the real implementation.

The object form still works for string and symbol keys.

createTestModule ​

Build a quick test module that explicitly registers dependencies and declares routes. Useful for integration tests that need fine-grained control over the DI graph.

typescript
function createTestModule(config: {
  register: (container: Container) => void
  routes: () => ModuleRoutes | ModuleRoutes[] | null
}): AppModuleClass

Example:

typescript
const TestModule = createTestModule({
  register: (c) => {
    c.registerInstance('repo', new MockRepo())
  },
  routes: () => ({
    path: '/items',
    router: buildRoutes(ItemController),
    controller: ItemController,
  }),
})

const { app } = await createTestApp({ modules: [TestModule] })

CreateTestAppOptions ​

typescript
interface CreateTestAppOptions {
  /** Class form (`class UserModule extends AppModule`) or `defineModule` factory output (`UserModule()`). */
  modules: AppModuleEntry[]
  /** Adapters to attach (queue, devtools, your own auth adapter, …). */
  adapters?: AppAdapter[]
  /** DI overrides applied after module registration. Entries or a Map preserve token identity. */
  overrides?:
    | Record<symbol | string, any>
    | ReadonlyArray<readonly [token: unknown, value: unknown]>
    | ReadonlyMap<unknown, unknown>
  /** Isolated container instead of the global singleton — safe for concurrent tests. */
  isolated?: boolean

  // Forwarded verbatim to the underlying Application:
  /** The engine under test — `expressRuntime()` (default), `fastifyRuntime()`, `h3Runtime()`. */
  runtime?: HttpRuntime
  /** Mount the built-in health module. Default: true. */
  health?: boolean
  middlewares?: MiddlewareEntry[]
  port?: number
  apiPrefix?: string
  defaultVersion?: number | false
  contributors?: ContributorRegistration[]
  contextStore?: 'auto' | 'manual'
  plugins?: KickPlugin[]
  onError?: ApplicationOptions['onError']
  onNotFound?: ApplicationOptions['onNotFound']
  trustProxy?: boolean | number | string
  jsonLimit?: string
  security?: ApplicationOptions['security']
}

createTestPlugin ​

Exercise a plugin's hooks without booting an app. Registers the plugin into a container and hands back lifecycle invokers plus whatever it contributed. Aliased as testPlugin.

typescript
async function createTestPlugin(
  plugin: KickPlugin,
  options?: CreateTestPluginOptions,
): Promise<PluginTestHarness>

interface CreateTestPluginOptions {
  /** Isolated container, never touching the global singleton. Default: true. */
  isolated?: boolean
  /** Skip the eager `plugin.register(container)` — to assert container state before it runs. Default: false. */
  skipRegister?: boolean
}

interface PluginTestHarness {
  readonly plugin: KickPlugin
  readonly container: Container

  callOnReady(): Promise<void>
  shutdown(): Promise<void>

  /** What the plugin ships. These are getters, not properties — call them. */
  modules(): AppModuleEntry[]
  adapters(): AppAdapter[]
  middleware(): any[]
  contributors(): ContributorRegistration[]

  makeContext(initial?: Record<string, unknown>): ExecutionContext
  runContributors(ctx: ExecutionContext): Promise<void>
}

modules() returns the module classes without instantiating them — use createTestApp for that. It answers "does the plugin expose the module I expect", not "does that module work".

runContributor ​

Run a single Context Contributor in isolation against a fake ExecutionContext. Skips the DI container, the topo-sort, and the §20.9 error matrix — calls decorator.registration.resolve(ctx, deps) directly so unit tests can assert pure resolve behaviour.

typescript
async function runContributor<K extends string, D extends Record<string, any>>(
  decorator: ContextDecorator<K, D, ExecutionContext>,
  options?: {
    /** Resolved deps passed to resolve() — skips container lookup. */
    deps?: Record<string, unknown>
    /** Pre-populates the fake ctx metadata so dependsOn-style reads succeed. */
    initial?: Record<string, unknown>
    /** Override the fake ctx requestId (default: 'test-req'). */
    requestId?: string
    /** More fields on the fake ctx, e.g. `{ req: { headers } }` for HTTP contributors. */
    ctx?: Record<string, unknown>
    /** Env values getEnv / ConfigService / @Value() return during resolve(), restored after. */
    env?: Record<string, unknown>
  },
): Promise<{
  /** Value returned by resolve() — typed via ContextMeta[K]. */
  value: MetaValue<K>
  /** The fake ExecutionContext used during the run. */
  ctx: ExecutionContext
  /** Final state of the metadata Map (includes any ctx.set() side effects). */
  meta: Map<string, unknown>
}>
ts
import { runContributor } from '@forinda/kickjs-testing'
import { defineContextDecorator } from '@forinda/kickjs'

const LoadProject = defineContextDecorator({
  key: 'project',
  dependsOn: ['tenant'],
  deps: { repo: ProjectsRepo },
  resolve: (ctx, { repo }) => (repo as ProjectsRepo).find(ctx.require('tenant').id, 'p-1'),
})

const { value } = await runContributor(LoadProject, {
  initial: { tenant: { id: 't-1' } },
  deps: { repo: new InMemoryProjectsRepo([{ id: 'p-1', tenantId: 't-1' }]) },
})
expect(value).toEqual({ id: 'p-1', tenantId: 't-1' })

Errors thrown by resolve() propagate so tests can await expect(...).rejects.toThrow() against them. To exercise the full §20.9 error matrix (optional skip, onError replacement), build a one-element pipeline with buildPipeline() and use runContributors() from @forinda/kickjs instead.

See Context Decorators for the full pipeline reference.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.