Testing
Unit test loaders, API routes, middleware, and forms with Vitest and @pracht/test, verify rendering and hydration end to end with Playwright, and prove your agent surfaces with capability tests and pracht eval.
Recommended Setup
Use Vitest for unit and integration tests and Playwright for E2E browser tests. @pracht/test adds typed args factories, a middleware chain runner, form submission helpers, and response readers.
# Install test dependencies
pnpm add -D vitest @playwright/test @pracht/testUnit Testing Loaders & API Routes
Loaders and API route handlers are plain async functions. @pracht/test builds their full args (request, params, url, context, signal, route metadata) from a shorthand, with a default for every field.
Testing a loader
import { describe, it, expect } from "vitest";
import { createLoaderArgs } from "@pracht/test";
import { loader } from "./dashboard";
describe("dashboard loader", () => {
it("returns projects for the authenticated user", async () => {
const data = await loader(
createLoaderArgs({
url: "/dashboard",
headers: { "x-user-id": "user-1" },
}),
);
expect(data.projects.length).toBeGreaterThan(0);
});
it("throws when no user header is present", async () => {
await expect(loader(createLoaderArgs({ url: "/dashboard" }))).rejects.toThrow();
});
});The shorthand accepts url (relative paths resolve against http://localhost), method, headers, body (plain objects are JSON-encoded), params, a partial context, and route overrides. A full request overrides them all. args.controller aborts args.signal:
const args = createLoaderArgs({ url: "/slow" });
const pending = loader(args);
args.controller.abort();
await expect(pending).rejects.toThrow();Testing an API route
createApiArgs() builds the same shape for API handlers, plain or defineApi()-wrapped. readJson() reads a response body without consuming it:
import type { ApiValidationErrorBody } from "@pracht/core";
import { describe, it, expect } from "vitest";
import { createApiArgs, readJson } from "@pracht/test";
import { GET, POST } from "./items";
describe("items API route", () => {
it("lists items", async () => {
const response = await GET(createApiArgs({ url: "/api/items?page=2" }));
expect(response.status).toBe(200);
expect(await readJson(response)).toEqual({ items: [], page: 2 });
});
it("creates an item from a JSON body", async () => {
const response = await POST(
createApiArgs({ url: "/api/items", body: { name: "Pracht" } }),
);
expect(await readJson(response)).toEqual({ created: "Pracht" });
});
it("rejects invalid input with the standardized validation body", async () => {
const response = await POST(createApiArgs({ url: "/api/items", body: { name: "" } }));
expect(response.status).toBe(422);
const body = await readJson<ApiValidationErrorBody>(response);
expect(body.issues).toEqual([{ in: "body", path: ["name"], message: "Required" }]);
});
});Testing form submissions
submitForm() builds the request a browser form sends and calls the handler with it. It encodes application/x-www-form-urlencoded, or multipart/form-data when any field is a File. createFormRequest() resolves to the same request without calling a handler. Both work under Vitest's JSDOM environment:
import { describe, it, expect } from "vitest";
import { readJson, submitForm } from "@pracht/test";
import { POST } from "./contact";
describe("contact API route", () => {
it("succeeds with valid input", async () => {
const response = await submitForm(POST, {
name: "Alice",
email: "alice@example.com",
message: "Hello!",
});
expect(response.status).toBe(200);
expect(await readJson(response)).toMatchObject({ ok: true });
});
it("validates required fields", async () => {
const response = await submitForm(POST, { name: "", email: "", message: "" });
expect(response.status).toBe(422);
});
it("accepts an uploaded file", async () => {
const response = await submitForm(POST, {
name: "Alice",
email: "alice@example.com",
message: "See attachment",
attachment: new File(["contents"], "notes.txt", { type: "text/plain" }),
});
expect(response.status).toBe(200);
});
});Pass repeated fields (multi-selects, checkbox groups) as arrays: { tag: ["a", "b"] } sends two tag entries. With method: "GET", the fields go into the query string, exercising a defineApi() query schema instead of body.
Testing Middleware
runMiddleware() runs one middleware, or a chain, the way the runtime does: a middleware that returns its own Response short-circuits the chain. The optional final handler stands in for the loader (default: an empty 200):
import { describe, it, expect } from "vitest";
import { createMiddlewareArgs, readRedirect, runMiddleware } from "@pracht/test";
import { middleware as auth } from "./auth";
describe("auth middleware", () => {
it("redirects when no session cookie is present", async () => {
const response = await runMiddleware(auth, createMiddlewareArgs({ url: "/dashboard" }));
expect(readRedirect(response)).toEqual({ status: 302, location: "/login" });
});
it("continues to the handler when the session is valid", async () => {
const response = await runMiddleware(
auth,
createMiddlewareArgs({
url: "/dashboard",
headers: { cookie: "session=valid-token-here" },
}),
async () => new Response("handler ran"),
);
expect(await response.text()).toBe("handler ran");
});
});For middleware attached through defineApp({ api: { middleware: [...] } }), build args with createApiMiddlewareArgs() instead of createMiddlewareArgs().
A thrown Response resolves as the result, as page and API dispatch send it as-is. Capability dispatch maps it to an internal_error instead: test that with createCapabilityTestHost(), or opt into rejection:
const args = createMiddlewareArgs({ url: "/dashboard" });
await expect(
runMiddleware(auth, args, undefined, { thrownResponse: "reject" }),
).rejects.toBeInstanceOf(Response);Other thrown errors, including notFound(), always reject.
For a chain, pass the middleware in manifest order; context changes flow downstream:
const args = createMiddlewareArgs<AppContext>({ url: "/admin", context: {} });
const response = await runMiddleware([logging, auth, requireAdmin], args, async () => {
// Sees the context that auth populated, like a loader would.
return Response.json({ user: args.context.user });
});Testing the Request Pipeline
For integration tests, handlePrachtRequest() runs the full server pipeline (middleware, loaders, rendering) without a browser:
import { describe, it, expect } from "vitest";
import { defineApp, handlePrachtRequest, resolveApp, route } from "@pracht/core";
// Build a test app with mock modules
const app = resolveApp(
defineApp({
shells: { main: "./shells/main.tsx" },
routes: [route("/", "./routes/home.tsx", { shell: "main", render: "ssr" })],
}),
);
const registry = {
routeModules: {
"./routes/home.tsx": async () => ({
Component: ({ data }) => `<h1>${data.title}</h1>`,
loader: async () => ({ title: "Home" }),
head: ({ data }) => ({ title: data.title }),
}),
},
shellModules: {
"./shells/main.tsx": async () => ({
Shell: ({ children }) => `<div>${children}</div>`,
}),
},
middlewareModules: {},
};
describe("request pipeline", () => {
it("renders the home page with loader data", async () => {
const response = await handlePrachtRequest({
request: new Request("http://localhost/"),
app,
registry,
});
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain("Home");
});
it("returns loader data as JSON for client navigation", async () => {
const response = await handlePrachtRequest({
request: new Request("http://localhost/", {
headers: { "x-pracht-route-state-request": "1" },
}),
app,
registry,
});
const json = await response.json();
expect(json.data.title).toBe("Home");
});
});E2E Testing with Playwright
E2E tests run your app in a real browser to verify hydration, client navigation, and form submissions.
Configuration
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
webServer: {
command: "pnpm dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});Testing SSR output
import { test, expect } from "@playwright/test";
test("home page renders with server data", async ({ page }) => {
await page.goto("/");
// Check server-rendered content
await expect(page.locator("h1")).toHaveText("Welcome");
// Verify the page title from head()
await expect(page).toHaveTitle(/Welcome/);
});
test("returns correct status for missing pages", async ({ request }) => {
const response = await request.get("/nonexistent");
expect(response.status()).toBe(404);
});Testing client-side navigation
import { test, expect } from "@playwright/test";
test("navigates between pages without full reload", async ({ page }) => {
await page.goto("/");
// Wait for hydration
await page.waitForFunction(() => (window as any).__PRACHT_ROUTER_READY__);
// Click a link
await page.click('a[href="/about"]');
// URL updated
await expect(page).toHaveURL("/about");
// Content updated without full page reload
await expect(page.locator("h1")).toHaveText("About");
});
test("shell persists across same-shell navigations", async ({ page }) => {
await page.goto("/");
await page.waitForFunction(() => (window as any).__PRACHT_ROUTER_READY__);
// Mark the shell DOM to verify it's not re-mounted
await page.evaluate(() => {
document.querySelector(".shell")?.setAttribute("data-test", "mounted");
});
await page.click('a[href="/about"]');
await expect(page).toHaveURL("/about");
// Shell element should still have our marker
const marker = await page.getAttribute(".shell", "data-test");
expect(marker).toBe("mounted");
});Testing form submissions
import { test, expect } from "@playwright/test";
test("submits contact form and shows success", async ({ page }) => {
await page.goto("/contact");
await page.waitForFunction(() => (window as any).__PRACHT_ROUTER_READY__);
await page.fill('input[name="name"]', "Alice");
await page.fill('input[name="email"]', "alice@example.com");
await page.fill('textarea[name="message"]', "Hello!");
await page.click('button[type="submit"]');
await expect(page.locator(".success")).toBeVisible();
});
test("shows validation errors on empty submit", async ({ page }) => {
await page.goto("/contact");
await page.waitForFunction(() => (window as any).__PRACHT_ROUTER_READY__);
await page.click('button[type="submit"]');
await expect(page.locator(".field-error")).toHaveCount(3);
});Testing API routes
import { test, expect } from "@playwright/test";
test("GET /api/health returns ok", async ({ request }) => {
const response = await request.get("/api/health");
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ status: "ok" });
});
test("POST /api/echo returns the body", async ({ request }) => {
const response = await request.post("/api/echo", {
data: { message: "hello" },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.message).toBe("hello");
});
test("unsupported methods return 405", async ({ request }) => {
const response = await request.delete("/api/health");
expect(response.status()).toBe(405);
});Testing Route Data (JSON Endpoint)
Client navigation fetches loader data as JSON when it sends x-pracht-route-state-request: 1. Test it directly:
test("loader returns JSON for client navigation requests", async ({ request }) => {
const response = await request.get("/dashboard", {
headers: { "x-pracht-route-state-request": "1" },
});
expect(response.status()).toBe(200);
const json = await response.json();
expect(json.data.projects).toBeDefined();
});Testing Capabilities & Agent Surfaces
Capabilities are testable at three levels: unit test the run() function, E2E test the HTTP projection, and script whole agent flows with pracht eval.
Unit testing run()
A capability module's default export carries run(). Call it directly to test the business logic:
import { describe, it, expect } from "vitest";
import notesSearch from "./notes-search";
describe("notes.search", () => {
it("finds notes matching the query", async () => {
const result = await notesSearch.run({
input: { query: "roadmap", limit: 10 },
context: {},
request: new Request("http://localhost/api/capabilities/notes/search"),
signal: AbortSignal.timeout(5000),
});
expect(result.notes.length).toBeGreaterThan(0);
});
it("rejects out-of-range input", () => {
const result = notesSearch.validateInput({ query: "roadmap", limit: 99 });
expect(result).toEqual({
ok: false,
issues: [{ path: "/limit", message: "must be <= 20" }],
});
});
});It also carries validateInput() / validateOutput(), the validators dispatch uses, schema defaults included. Calling run() directly skips validation, middleware, and the confirmation flow; for those, use a test host.
The full pipeline without a server
createCapabilityTestHost() runs the real dispatch pipeline in-process, without a server.
invoke()mirrorsinvokeCapability(), typed from the capability map you pass the host.request()mirrors the HTTP endpoints, including agent policy, simulated agent identity, and the confirmation flow.
For typed output, annotate run with CapabilityRunArgs<Input> or pass both defineCapability<Input, Output> generics. Passing only Input leaves the output unknown:
import { CONFIRMATION_HEADER, createCapabilityTestHost, setCapabilityConfirmationSecret } from "@pracht/core/server";
import notesSearch from "./notes-search";
import notesPurge from "./notes-purge";
const host = createCapabilityTestHost({
capabilities: { "notes.search": notesSearch, "notes.purge": notesPurge },
middleware: { auth: authMiddleware }, // for capabilities declaring middleware: ["auth"]
});
it("runs validation, middleware, run(), and output validation", async () => {
const result = await host.invoke("notes.search", { query: "roadmap" });
expect(result.ok).toBe(true);
});
it("walks the prepare/commit confirmation flow", async () => {
setCapabilityConfirmationSecret("test-only-secret");
const prepare = await host.request("notes.purge", { titlePrefix: "Old" });
expect(prepare.status).toBe(409);
const { error } = await prepare.json();
const commit = await host.request("notes.purge", { titlePrefix: "Old" }, {
headers: { [CONFIRMATION_HEADER]: error.confirmationToken },
});
expect(commit.status).toBe(200);
});To test agentPolicy: "require" and context.agent, inject a simulated verified identity instead of signing requests:
const response = await host.request("agent.ping", {}, {
agent: { verified: true, agentDomain: "test-agent.example", keyId: "test-key" },
});
expect(response.status).toBe(200);E2E testing the HTTP projection
Every exposed capability answers at POST /api/capabilities/<name> with a typed envelope:
import { test, expect } from "@playwright/test";
test("capability answers with the ok envelope", async ({ request }) => {
const response = await request.post("/api/capabilities/notes/search", {
data: { query: "roadmap" },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.ok).toBe(true);
expect(Array.isArray(body.data.notes)).toBe(true);
});
test("invalid input returns path-scoped issues", async ({ request }) => {
const response = await request.post("/api/capabilities/notes/search", {
data: { query: "", limit: 99 },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error.code).toBe("invalid_input");
expect(body.error.issues).toEqual([
{ path: "/query", message: "must be at least 1 character(s) long" },
{ path: "/limit", message: "must be <= 20" },
]);
});Testing the destructive confirmation flow
destructive capabilities need PRACHT_CONFIRMATION_SECRET in the server environment. Set it on Playwright's webServer:
webServer: {
command: "pnpm dev",
port: 3000,
env: { PRACHT_CONFIRMATION_SECRET: "test-only-secret" },
},Then assert the prepare/commit handshake. The first call must not run the capability:
import { CONFIRMATION_HEADER } from "@pracht/capabilities";
test("destructive capability requires confirmation, then commits", async ({ request }) => {
// Prepare: no token → 409 with a confirmation token, nothing deleted.
const prepare = await request.post("/api/capabilities/notes/purge", {
data: { titlePrefix: "Old" },
});
expect(prepare.status()).toBe(409);
const { error } = await prepare.json();
expect(error.code).toBe("confirmation_required");
// Commit: identical input + the token → runs.
const commit = await request.post("/api/capabilities/notes/purge", {
data: { titlePrefix: "Old" },
headers: { [CONFIRMATION_HEADER]: error.confirmationToken },
});
expect(commit.status()).toBe(200);
});Also assert that a tampered token, or the same token with different input, gets a 403.
Verify native WebMCP in Chrome
pracht verify checks declarations and graph wiring without a browser. When
routes expose WebMCP, add the live check:
pracht verify webmcp --start "pracht preview"
# Pin the executable in CI; Pracht never downloads an unpinned browser.
pracht verify webmcp --start "pracht preview" \
--browser /path/to/chrome --jsonIt loads every tool-registering route in Chrome 150+, compares the registered
tools with pracht's graph, and checks cleanup after navigation. Each failure
(unsupported build, startup, registration, graph drift) exits non-zero with its
own report status.
A registered tool is not necessarily safe to call. Add a WebMCP eval scenario only for inputs known to be safe:
{
"name": "live notes page tool",
"transport": "webmcp",
"webmcpRoute": "/notes",
"steps": [
{
"capability": "notes.search",
"input": { "query": "roadmap" },
"expect": { "ok": true }
},
{
"capability": "notes.search",
"input": { "query": "roadmap" },
"cancelAfterMs": 0,
"expect": { "ok": false, "status": 499, "errorCode": "cancelled" }
}
]
}Run it with pracht eval, or add its results to the verifier's JSON with
pracht verify webmcp --scenario evals/notes-webmcp.eval.json.
Faking WebMCP in the browser
For fast tests without a compatible Chrome, install a fake
document.modelContext before any page script runs. The client runtime
registers tools against it, and execute() still goes through the real HTTP
projection:
test("webmcp tools register and execute", async ({ page }) => {
await page.addInitScript(() => {
const registered: unknown[] = [];
(window as any).__webmcpTools = registered;
(document as any).modelContext = {
registerTool: (tool: unknown) => (registered.push(tool), Promise.resolve()),
};
});
await page.goto("/notes");
await page.waitForFunction(() => (window as any).__webmcpTools?.length);
const envelope = await page.evaluate(() => {
const tool = (window as any).__webmcpTools.find((t: any) => t.name === "notes.search");
return tool.execute({ query: "roadmap" });
});
// execute() resolves to the capability envelope as a plain value; the
// WebMCP host serializes it itself.
expect(envelope.ok).toBe(true);
});Signing Web Bot Auth requests in tests
The test host's agent option covers the pipeline. To test the verifier itself over the wire, sign requests like a real agent: generate an Ed25519 test keypair, put the public JWK in agents.webBotAuth.keys, and sign with the private half:
import { createPrivateKey, sign } from "node:crypto";
// Test-only keypair; the public `x` half lives in defineApp({ agents }).
const TEST_AGENT_JWK = { kty: "OKP", crv: "Ed25519", d: "<private>", x: "<public>" };
const KEY_ID = "<RFC 7638 JWK thumbprint of the public key>";
export function webBotAuthHeaders(authority: string): Record<string, string> {
const now = Math.floor(Date.now() / 1000);
const signatureAgent = '"https://test-agent.example"';
const params =
`("@authority" "signature-agent");created=${now};expires=${now + 300}` +
`;keyid="${KEY_ID}";alg="ed25519";tag="web-bot-auth"`;
const base = [
`"@authority": ${authority}`,
`"signature-agent": ${signatureAgent}`,
`"@signature-params": ${params}`,
].join("\n");
const key = createPrivateKey({ key: TEST_AGENT_JWK, format: "jwk" });
const signature = sign(null, Buffer.from(base, "utf-8"), key);
return {
"signature-agent": signatureAgent,
"signature-input": `sig1=${params}`,
signature: `sig1=:${signature.toString("base64")}:`,
};
}test("verified agents pass agentPolicy: require", async ({ request }) => {
const response = await request.post("/api/capabilities/agent/ping", {
data: {},
headers: webBotAuthHeaders("localhost:3000"),
});
expect(response.status()).toBe(200);
});
test("unsigned requests are rejected", async ({ request }) => {
const response = await request.post("/api/capabilities/agent/ping", { data: {} });
expect(response.status()).toBe(401);
expect((await response.json()).error.code).toBe("agent_required");
});Scripted agent flows with pracht eval
pracht eval runs multi-step scenarios against a live server and exits 1 on any failed expectation. Scenarios live in evals/**/*.eval.json; $steps[n].<path> passes values such as confirmation tokens between steps:
# One command: start the app, wait for it, run the scenarios, stop it.
pracht eval --start "pracht preview" # add --json for machine-readable CI output
# Or point at a server you manage yourself:
pracht eval --url http://localhost:3000Set "transport": "mcp" to run the steps against your app's remote MCP endpoint, or "transport": "webmcp" plus "webmcpRoute" to call the page tool in Chrome. A step's cancelAfterMs tests cancellation.
See Agent Trust for the scenario format, and the framework repository's examples/basic for unit, E2E, and eval coverage over HTTP, remote MCP, and WebMCP.
Vitest Configuration
A minimal vitest.config.ts for a pracht app:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Exclude E2E tests (run those with Playwright)
exclude: ["e2e/**", "node_modules/**"],
},
});Test Scripts
Add these to your package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"check": "pnpm build && pnpm typecheck && pnpm test"
}
}Tips
- Unit tests cannot verify hydration or client routing; use Playwright for those.
- Wait for
(window as any).__PRACHT_ROUTER_READY__before interacting with the page in Playwright. - Keep E2E tests on behavior (navigation, form flows, error states), not visuals.