LangENKO

Unit Tests

Run fast, pure-function tests against fillings and handlers without booting a server — `testFilling`, `createTestRequest`, `createTestContext` via `@mandujs/core/testing`.

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

🤖 Agent Prompt — Unit Tests
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.
  • 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 --coverage for 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

AI hint

`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`.

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