Integration Tests
Exercise the full HTTP stack with `createTestServer` + `createTestSession` + `createTestDb` — from `@mandujs/core/testing`. Each fixture implements `Symbol.asyncDispose` so `await using` handles teardown.
On this page
Integration Tests
Integration tests boot a real HTTP listener and exercise the full request/response pipeline — middleware, routes, DB queries, cookies. Mandu gives you four fixtures to keep them honest and fast:
| Fixture | Purpose |
|---|---|
createTestServer(manifest) |
Boot Bun.serve on port 0 |
createTestSession({ userId }) |
Build an authenticated cookie jar |
createTestDb({ schema, seed }) |
Spin up an isolated sqlite db |
mockMail() / mockStorage() |
Drop-in replacements for email + S3 |
mandu test integration
mandu test integration --filter dashboard
mandu test integration --watch
The shape of an integration test
// tests/integration/dashboard.test.ts
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
createTestServer,
createTestSession,
createTestDb,
type TestServer,
type TestDb,
} from "@mandujs/core/testing";
import { manifest, registerHandlers } from "../../src/runtime";
describe("dashboard", () => {
let server: TestServer;
let db: TestDb;
beforeEach(async () => {
db = await createTestDb({
schema: `
CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL);
CREATE TABLE posts (id TEXT PRIMARY KEY, owner TEXT, title TEXT);
`,
seed: async (d) => {
await d`INSERT INTO users VALUES (${"u1"}, ${"alice@x.y"})`;
},
});
server = await createTestServer(manifest, {
registerHandlers: (reg) => registerHandlers(reg, { db: db.db }),
});
});
afterEach(async () => {
server.close();
await db.close();
});
test("redirects to /login when unauthenticated", async () => {
const res = await server.fetch("/dashboard", { redirect: "manual" });
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
test("renders dashboard when authenticated", async () => {
const authed = await createTestSession({ userId: "u1" });
const res = await server.fetch("/dashboard", { headers: authed.headers });
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Welcome back, alice@x.y");
});
});
await using — automatic teardown
Every async fixture implements Symbol.asyncDispose, so you can skip
the beforeEach/afterEach boilerplate entirely:
test("all fixtures in one test", async () => {
await using server = await createTestServer(manifest);
await using db = await createTestDb({ schema: "..." });
using mail = mockMail();
using storage = mockStorage();
// teardown runs automatically in reverse order at scope exit
});
Use this pattern when a test needs bespoke fixtures; keep beforeEach
for shared suite setup.
Why a session fixture, not a POST to /login?
Your login endpoint is behind CSRF, rate limit, maybe 2FA. Tests that need "given a logged-in user, when…" should not re-validate the login path — they should start from an authenticated state directly:
const authed = await createTestSession({ userId: "u1", roles: ["admin"] });
const res = await server.fetch("/admin/posts", { headers: authed.headers });
For tests of the login path itself, call it directly:
const res = await server.fetch("/login", {
method: "POST",
body: new URLSearchParams({ email: "x@y.z", password: "secret" }),
});
createTestDb — isolated sqlite
const db = await createTestDb({
schema: `
CREATE TABLE todos (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
);
`,
seed: async (d) => {
await d`INSERT INTO todos VALUES (${"t1"}, ${"u1"}, ${"buy milk"}, ${0})`;
},
});
// Inside a test
const rows = await db.db`SELECT * FROM todos WHERE owner = ${"u1"}`;
expect(rows).toHaveLength(1);
Each call to createTestDb returns a fresh in-memory sqlite
instance. No shared state across tests unless you explicitly connect
to the same dbUrl.
For a file-backed DB (e.g. to inspect state post-hoc):
const db = await createTestDb({ url: "sqlite:./.mandu/test.db" });
Registering DI for the server
The default createTestServer(manifest) works for projects that wire
DI inside handler modules. If your handlers take injected dependencies,
use registerHandlers:
server = await createTestServer(manifest, {
registerHandlers: (reg) => registerHandlers(reg, {
db: db.db,
mail: mockMail(),
storage: mockStorage(),
}),
});
registerHandlers receives the route registry and is called once per
createTestServer instance. Use it to attach per-test deps — the
injected deps are visible to every route invocation for the duration
of the server.
Mocking external I/O
mockMail() and mockStorage() replace the real EmailSender and
S3Client interfaces. They capture calls and expose assertion helpers:
const mail = mockMail();
await sendVerificationEmail({ mail }, "user@x.y");
expect(mail.count()).toBe(1);
expect(mail.lastTo("user@x.y")?.subject).toMatch(/verify/i);
const storage = mockStorage();
await uploadAvatar({ storage }, "u1", new Uint8Array([0x89, 0x50]));
expect(await storage.exists("u/u1.png")).toBe(true);
const obj = storage.peek("u/u1.png");
expect(obj?.contentType).toBe("image/png");
Both mocks reset themselves when disposed (using or explicit
.clear()).
API surface
| Fixture | Returns | Cleanup |
|---|---|---|
testFilling(filling, opts?) |
Promise<Response> |
— (pure call) |
createTestRequest(path, opts?) |
Request |
— (plain object) |
createTestContext(path, opts?) |
ManduContext |
— (plain object) |
createTestServer(manifest, opts?) |
Promise<TestServer> |
.close() / asyncDispose |
createTestSession(opts) |
Promise<TestSession> |
— (cookie jar only) |
createTestDb(opts?) |
Promise<TestDb> |
.close() / asyncDispose |
mockMail() |
MockMail |
.clear() / dispose |
mockStorage(opts?) |
MockStorage |
.clear() / dispose |
Config
// mandu.config.ts
export default {
test: {
integration: {
include: ["tests/integration/**/*.test.ts"], // default
dbUrl: "sqlite::memory:", // default
sessionStore: "memory", // or "sqlite"
timeout: 60_000, // default
},
},
};
🤖 Agent Prompt
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/testing/integration to my project.
Summary of the page:
Integration tests boot a real `Bun.serve` listener on an ephemeral port. Use `createTestSession()` to bypass login. Use `createTestDb({schema, seed})` for an isolated sqlite instance. Every async fixture supports `await using` — no manual `.close()` needed.
Required invariants — must hold after your changes:
- Default include glob: `tests/integration/**/*.test.ts`
- `createTestServer(manifest, opts)` binds to port 0 (ephemeral) — tests run in parallel safely
- Every async fixture implements `Symbol.asyncDispose` — `await using` cleans up in reverse order
- The default `dbUrl` is `sqlite::memory:` — per-test isolation unless you point at a shared URL
- Integration test timeout defaults to 60_000 ms (override via `test.integration.timeout`)
Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.
Related
- Unit tests — pure filling tests without a server.
- E2E tests — browser-level tests via ATE + Playwright.
- Coverage — merge unit + integration + E2E LCOV.
For Agents
{
"schema": "mandu.testing.integration/v0.24",
"command": "mandu test integration",
"runner": "bun:test",
"default_include": ["tests/integration/**/*.test.ts"],
"timeout_ms_default": 60000,
"fixtures": {
"createTestServer": "boots Bun.serve on port 0, TestServer",
"createTestSession": "builds authenticated cookie headers",
"createTestDb": "isolated sqlite — default sqlite::memory:",
"mockMail": "EmailSender mock",
"mockStorage": "S3Client mock"
},
"disposal": {
"async": "createTestServer, createTestDb — Symbol.asyncDispose",
"sync": "mockMail, mockStorage — Symbol.dispose"
},
"rules": [
"Use `createTestSession` instead of POSTing to /login — faster, bypasses CSRF/2FA",
"Per-test `createTestDb` defaults to `:memory:` — parallel-safe",
"Prefer `await using` over `beforeEach`/`afterEach` for bespoke fixtures"
]
}For Agents
Integration tests boot a real `Bun.serve` listener on an ephemeral port. Use `createTestSession()` to bypass login. Use `createTestDb({schema, seed})` for an isolated sqlite instance. Every async fixture supports `await using` — no manual `.close()` needed.
- Default include glob: `tests/integration/**/*.test.ts`
- `createTestServer(manifest, opts)` binds to port 0 (ephemeral) — tests run in parallel safely
- Every async fixture implements `Symbol.asyncDispose` — `await using` cleans up in reverse order
- The default `dbUrl` is `sqlite::memory:` — per-test isolation unless you point at a shared URL
- Integration test timeout defaults to 60_000 ms (override via `test.integration.timeout`)