Skip to content

Replace services in tests

Replace a registered service with a test double in an integration test at the DI boundary with app.test({ replace }) and handle.reset({ replace }).

AI generated, pending review Updated 10 days ago · Flare 0.3

You want one route in an integration test to hit a fake instead of the real thing: a database that returns canned rows, a mailer that records calls instead of sending. Flare swaps the service at the DI boundary, so the route, the handler, and every inject call site stay exactly as they ship. Pass a replace map to app.test() or handle.reset(), and the substituted graph still runs the validator suite before any request flows.

This is not module mocking and it is not new RealService() by hand. The double is a real registration that takes the original’s place in the container.

  1. Register the real service on the host as usual (host.scoped() or host.singleton()).
  2. Write a double that extends the real service and overrides the methods you want faked.
  3. Build the test app and pass replace: new Map([[Real, Double]]) to app.test().
  4. Send a request with handle.fetch and assert on the Response.
import { FlareHost, FlareResponse, FlareService } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
import { strict as assert } from "node:assert";
// FLARE_MODE=test must be set before this module loads (see Notes).
class Db extends FlareService {
public static override deps = [];
lookup(): { ok: boolean; source: string } {
return { ok: true, source: "real" };
}
}
const host = new FlareHost(node);
host.scoped(Db);
host.http.get(
"/health",
{ inject: { db: Db } },
(_ctx, scope) => new FlareResponse(200, scope.db.lookup()),
);
class StubDb extends Db {
override lookup() {
return { ok: true, source: "stub" };
}
}
const handle = await host.build().test({ replace: new Map([[Db, StubDb]]) });
const res = await handle.fetch("GET /health");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true, source: "stub" });
await handle.stop();

The route’s scope.db resolves a StubDb instance because the container now answers the Db token with the replacement class. The handler code never changes.

app.test() runs once per host instance. To change the double between cases in one file, call handle.reset({ replace }). It tears the app down, restores the original registrations, applies the new map, and restarts on the same handle.

import { FlareHost, FlareResponse, FlareService } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
import { afterAll, beforeAll, expect, it } from "vitest";
import type { TestAppHandle } from "@flare-ts/core/testing";
class Db extends FlareService {
public static override deps = [];
lookup(): { ok: boolean; source: string } {
return { ok: true, source: "real" };
}
}
class StubDb extends Db {
override lookup() {
return { ok: true, source: "stub" };
}
}
const host = new FlareHost(node);
host.scoped(Db);
host.http.get(
"/health",
{ inject: { db: Db } },
(_ctx, scope) => new FlareResponse(200, scope.db.lookup()),
);
let handle: TestAppHandle;
beforeAll(async () => {
handle = await host.build().test();
});
afterAll(async () => {
await handle.stop();
});
it("hits the real Db by default", async () => {
await handle.reset();
const res = await handle.fetch("GET /health");
expect(await res.json()).toEqual({ ok: true, source: "real" });
});
it("hits the stub when replaced", async () => {
await handle.reset({ replace: new Map([[Db, StubDb]]) });
const res = await handle.fetch("GET /health");
expect(await res.json()).toEqual({ ok: true, source: "stub" });
});

replace is a Map<ServiceToken, ServiceClass>. The exported AppTestOptions type is the shape both app.test() and handle.reset() accept.

Flare checks this before the substituted graph compiles:

  • The map key must be a registered service token.
  • The replacement class must extend the token (instanceof is checked).
  • After substitution, the service validator re-runs against the post-replacement graph.

A double with invalid static deps fails setup with FlareTestError, not an assertion inside your test body.

When you want a class under test on its own, with no app and no HTTP pipeline, use mockContainer from @flare-ts/core/testing. See Unit-test a controller and Using DI in apps.

  • FLARE_MODE=test must be set before the host module is imported.
  • Singletons are instantiated at app.test() / handle.reset() in test mode, not at host.build(), so replace substitutes the class before any constructor runs.
  • handle.reset() with no args restores the original registrations. Call handle.stop() in afterAll so singleton onStop() hooks run.