LangENKO

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.

since v0.24
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

🤖 Agent Prompt — Integration Tests
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.
  • 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

AI hint

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.

Invariants
  • 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`)
Guard scope
testing