Unit-test a controller
Call a single controller or service method in isolation with mockContext and mockContainer, no app, no HTTP pipeline.
You want to test the logic inside one controller method, not the route that reaches it. No routing, no middleware, no contract validation. Construct the controller, call the method, and assert on what it returns. Flare gives you two helpers in @flare-ts/core/testing: mockContext builds the FlareHttpContext the controller reads from, and mockContainer builds the DI container it resolves dependencies from. Neither boots the app.
This is the unit tier. It is fast because it skips the pipeline entirely. When you need the request to travel routing, middleware, and contract validation, use Write your first test instead.
The two helpers
Section titled “The two helpers”A controller’s constructor takes a container and a context: new SomeController(container, ctx).
mockContext(opts?)builds aFlareHttpContextwith a synthetic request.mockContainer(services)builds a DI container from aMapof service token to a pre-built fake. Missing tokens throw atinject()time.
import { mockContext, mockContainer } from "@flare-ts/core/testing";Test a method with no dependencies
Section titled “Test a method with no dependencies”Pass an empty mockContainer and a mockContext carrying whatever the method reads. Seed route params as a Map, then call the method and assert on the returned value.
import { ControllerBase, FlareResponse } from "@flare-ts/core";import { mockContainer, mockContext } from "@flare-ts/core/testing";import { strict as assert } from "node:assert";
class GreetController extends ControllerBase { public static override deps = []; public static override state = [];
greet() { const name = this.ctx.req.rawRouteParams.name ?? "world"; return this.ok({ greeting: `hi ${name}` }); }}
const ctx = mockContext({ params: new Map([["name", "ada"]]) });const controller = new GreetController(mockContainer(new Map()), ctx);
const res = controller.greet();if (!(res instanceof FlareResponse)) throw new Error("expected a FlareResponse");assert.equal(res.status, 200);assert.deepEqual(res.jsonBody, { greeting: "hi ada" });Assert on res.status and res.jsonBody, not res.body. The unit path never runs the per-status serializer that produces a Web Response.
mockContext defaults to GET / with no body and no state:
| Field | Default | What it seeds |
|---|---|---|
method | "GET" | ctx.req.method |
url | "/" | ctx.req.url (query string included) |
headers | none | ctx.req.headers |
params | none | ctx.req.rawRouteParams (a Record<string, string>); pass the input as a Map |
state | none | ctx.state as Map<StateToken, unknown> |
body | none | Raw bytes only (ArrayBuffer / Uint8Array) |
requestId | "mock-req" | ctx.req.requestId |
Body is raw bytes on purpose. Encode with TextEncoder when you need a JSON body.
Test a method with a dependency
Section titled “Test a method with a dependency”Put a double for the token in the mockContainer map. The controller still declares static deps = [UserRepo], so this.inject(UserRepo) passes the same guardrail as in production.
import { ControllerBase, FlareResponse, FlareService } from "@flare-ts/core";import { mockContainer, mockContext } from "@flare-ts/core/testing";import { strict as assert } from "node:assert";
class UserRepo extends FlareService { public static override deps = []; find(id: string) { return { id, name: "real" }; }}
class UserController extends ControllerBase { public static override deps = [UserRepo]; public static override state = [];
show() { const id = this.ctx.req.rawRouteParams.id ?? "0"; const user = this.inject(UserRepo).find(id); return this.ok(user); }}
class FakeUserRepo extends UserRepo { constructor() { super(undefined as never); } override find(id: string) { return { id, name: "stub" }; }}
const ctx = mockContext({ params: new Map([["id", "42"]]) });const container = mockContainer(new Map([[UserRepo, new FakeUserRepo()]]));const controller = new UserController(container, ctx);
const res = controller.show();if (!(res instanceof FlareResponse)) throw new Error("expected a FlareResponse");assert.equal(res.status, 200);assert.deepEqual(res.jsonBody, { id: "42", name: "stub" });A token you forget to put in the map throws ServiceToken UserRepo not registered in container. when the method tries to resolve it.
Test a service directly
Section titled “Test a service directly”Construct a service with a faked container and call its methods without any controller or context.
import { FlareService } from "@flare-ts/core";import { mockContainer } from "@flare-ts/core/testing";import { strict as assert } from "node:assert";
class Clock extends FlareService { public static override deps = []; now() { return Date.now(); }}
class Stamper extends FlareService { public static override deps = [Clock]; stamp(label: string) { return `${label}@${this.inject(Clock).now()}`; }}
class FixedClock extends Clock { constructor() { super(undefined as never); } override now() { return 1000; }}
const container = mockContainer(new Map([[Clock, new FixedClock()]]));const stamper = new Stamper(container);
assert.equal(stamper.stamp("x"), "x@1000");When to reach for the pipeline instead
Section titled “When to reach for the pipeline instead”The unit tier proves the logic inside a method. It does not prove the route is wired, the middleware runs, or the contract rejects bad input. Reach for app.test() when you need any of that. To swap one real service for a fake while still traveling the full pipeline, see Replace services in tests.
See also
Section titled “See also”- Testing: the full
mockContextoption table and every export - Using DI in apps:
static depsandinjectguardrails