Unit Tests
Run fast, pure-function tests against fillings and handlers without booting a server — `testFilling`, `createTestRequest`, `createTestContext` via `@mandujs/core/testing`.
On this page
Unit Tests
Unit tests in Mandu are ordinary bun:test files. The framework only
adds two things: a test-oriented entry point (@mandujs/core/testing)
that lets you invoke fillings without a server, and a config-driven
include/exclude so mandu test unit picks up the right files.
# Run every *.test.ts / *.test.tsx in the project
mandu test unit
# Narrow with --filter (forwarded to `bun test`)
mandu test unit --filter login
# Watch mode with sub-second re-runs
mandu test unit --watch
The shape of a filling test
testFilling(route, opts) invokes the filling's handler directly. No
server, no bundle — a pure function call fast enough to run thousands
per second.
// src/api/todos.test.ts
import { test, expect } from "bun:test";
import { testFilling } from "@mandujs/core/testing";
import todoRoute from "../app/api/todos/route";
test("GET /api/todos returns current user's todos", async () => {
const res = await testFilling(todoRoute, {
method: "GET",
headers: { Cookie: "userId=u_42" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.todos).toHaveLength(3);
});
Options
testFilling(route, {
method: "POST",
path: "/api/todos", // default derived from route
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "buy milk" }),
cookies: { sessionId: "s_123" },
params: { id: "t_42" }, // dynamic route params
query: { page: "2" },
searchParams: new URLSearchParams("?q=1"),
});
Every field is optional — only method has a default (GET).
Building a request without calling a filling
When you want to test utilities that consume Request or ManduContext
directly, use the two sibling helpers:
import {
createTestRequest,
createTestContext,
} from "@mandujs/core/testing";
import { parseLocale } from "../src/shared/locale";
test("parseLocale falls back to en on missing header", () => {
const req = createTestRequest("/users", {
headers: { /* no Accept-Language */ },
});
expect(parseLocale(req)).toBe("en");
});
test("context wraps cookies + params", () => {
const ctx = createTestContext("/users/[id]", {
params: { id: "u_42" },
cookies: { session: "s_1" },
});
expect(ctx.params.id).toBe("u_42");
expect(ctx.cookies.get("session")).toBe("s_1");
});
Neither helper does any I/O — they return plain request/context objects you can assert against.
Mocks — mockMail and mockStorage
Drop-in replacements for the EmailSender and S3Client interfaces
that capture calls for assertion. Use them via dependency injection:
import { mockMail, mockStorage } from "@mandujs/core/testing";
import { sendVerificationEmail } from "../src/auth/verification";
test("sends a verification email on signup", async () => {
const mail = mockMail();
await sendVerificationEmail({ mail }, "new-user@x.y");
const message = mail.lastTo("new-user@x.y");
expect(message?.subject).toMatch(/verify/i);
expect(message?.body).toContain("Confirm your email");
});
test("stores uploaded avatar under u/<id>.png", async () => {
const storage = mockStorage();
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
await uploadAvatar({ storage }, "u1", buffer);
expect(await storage.exists("u/u1.png")).toBe(true);
expect(storage.peek("u/u1.png")?.contentType).toBe("image/png");
});
Auto-cleanup with using
Both mocks implement Symbol.dispose, so Bun's Explicit Resource
Management clears them at scope exit:
test("idempotent", () => {
using mail = mockMail(); // auto-clears on scope exit
using storage = mockStorage(); // auto-clears on scope exit
// ...no finally block needed
});
Co-locating unit tests
Mandu does not enforce a tests/ directory for unit tests. The
convention is co-location — a test file sits next to the code it
covers:
src/
├── api/
│ ├── todos.ts
│ └── todos.test.ts ← co-located
├── auth/
│ ├── verification.ts
│ └── verification.test.ts
└── shared/
└── locale/
├── index.ts
└── index.test.ts
This is the bun:test convention — refactoring a source file drags its test along in the same commit.
Filter patterns
--filter is forwarded verbatim to bun test --filter. It matches on
describe / test names, not file paths:
# Runs tests whose label contains "login"
mandu test unit --filter login
# Runs tests whose label matches /^auth/
mandu test unit --filter '^auth'
To narrow by file path, point at a single directory:
mandu test unit -- src/auth/
Arguments after -- are passed to bun test unchanged.
Common errors
CLI_E061: No unit test files matched the include glob. — the
default glob only looks for *.test.ts / *.test.tsx. If your files
end in .spec.ts, widen the include:
// mandu.config.ts
test: {
unit: {
include: ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"],
},
}
Timeout: test "..." exceeded 30000ms — a slow test. Either fix the
test (usually a missing await or a forgotten mock) or raise
test.unit.timeout for the whole project.
🤖 Agent Prompt
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/testing/unit to my project.
Summary of the page:
`mandu test unit` wraps `bun:test` and picks up `**/*.test.ts` + `**/*.test.tsx` by default. Use `testFilling(fn, opts)` to invoke a filling without a server. Every async fixture implements `Symbol.asyncDispose` for `await using`.
Required invariants — must hold after your changes:
- Default include glob: `**/*.test.ts`, `**/*.test.tsx`; exclude: `node_modules/**`, `.mandu/**`
- `testFilling` is a pure function call — no HTTP stack, no Bun.serve
- Unit test timeout defaults to 30_000 ms (override via `test.unit.timeout`)
- Mocks (`mockMail`, `mockStorage`) implement `Symbol.dispose` so `using` clears them on scope exit
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
- Integration tests — when you need a
real HTTP stack, use
createTestServer. - Watch mode — sub-second re-runs during the red-green-refactor loop.
- Coverage — add
--coveragefor LCOV.
For Agents
{
"schema": "mandu.testing.unit/v0.24",
"command": "mandu test unit",
"runner": "bun:test",
"default_include": ["**/*.test.ts", "**/*.test.tsx"],
"default_exclude": ["node_modules/**", ".mandu/**"],
"timeout_ms_default": 30000,
"fixtures": {
"testFilling": "invoke a filling without a server (pure call)",
"createTestRequest": "plain Request object",
"createTestContext": "plain ManduContext object",
"mockMail": "EmailSender mock, Symbol.dispose",
"mockStorage": "S3Client mock, Symbol.dispose"
},
"rules": [
"Co-locate tests next to source files (convention, not enforcement)",
"Prefer `testFilling` over booting `createTestServer` for pure handler tests",
"Use `using mail = mockMail()` for auto-cleanup on scope exit"
]
}For Agents
`mandu test unit` wraps `bun:test` and picks up `**/*.test.ts` + `**/*.test.tsx` by default. Use `testFilling(fn, opts)` to invoke a filling without a server. Every async fixture implements `Symbol.asyncDispose` for `await using`.
- Default include glob: `**/*.test.ts`, `**/*.test.tsx`; exclude: `node_modules/**`, `.mandu/**`
- `testFilling` is a pure function call — no HTTP stack, no Bun.serve
- Unit test timeout defaults to 30_000 ms (override via `test.unit.timeout`)
- Mocks (`mockMail`, `mockStorage`) implement `Symbol.dispose` so `using` clears them on scope exit