Testing Mage Applications
Testing Mage applications is straightforward: spin up an ephemeral HTTP server,
make requests with fetch, and assert on responses. No special testing
utilities needed—it's just HTTP API testing.
Quick Start
Here's the basic pattern:
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { MageApp } from "@mage/app";
let server: Deno.HttpServer;
let app: MageApp;
let baseUrl: string;
beforeAll(() => {
app = new MageApp();
app.get("/hello", (c) => {
c.text("Hello, World!");
});
server = Deno.serve({ port: 0 }, app.handler);
baseUrl = `http://${server.addr.hostname}:${server.addr.port}`;
});
afterAll(async () => {
await server.shutdown();
await server.finished;
});
describe("hello endpoint", () => {
it("should return greeting", async () => {
const response = await fetch(`${baseUrl}/hello`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("Hello, World!");
});
});
Run tests with:
deno test --allow-all
Test Server Setup
Use Port 0
Always use port: 0 when starting test servers. Deno assigns an available port
automatically, preventing "address already in use" errors in CI or when running
tests in parallel:
server = Deno.serve({ port: 0 }, app.handler);
Create a Helper Class
For cleaner tests, create a reusable test server helper:
export class MageTestServer {
private _app: MageApp = new MageApp();
private _server: Deno.HttpServer<Deno.NetAddr> | undefined;
public get app() {
return this._app;
}
start(port?: number) {
this._server = Deno.serve({ port: port ?? 0 }, this._app.handler);
}
url(path: string) {
return new URL(
path,
`http://${this._server?.addr.hostname}:${this._server?.addr.port}`,
);
}
async stop() {
if (this._server) {
await this._server.shutdown();
await this._server.finished;
}
}
}
Usage:
import { MageTestServer } from "./test-utils/server.ts";
let server: MageTestServer;
beforeAll(() => {
server = new MageTestServer();
server.app.get("/hello", (c) => {
c.text("Hello, World!");
});
server.start();
});
afterAll(async () => {
await server.stop();
});
it("should return greeting", async () => {
const response = await fetch(server.url("/hello"));
expect(await response.text()).toBe("Hello, World!");
});
Common Testing Scenarios
Different HTTP Methods
beforeAll(() => {
server = new MageTestServer();
server.app.get("/users", (c) => {
c.json([{ id: 1, name: "Alice" }]);
});
server.app.post("/users", async (c) => {
const user = await c.json();
c.json({ id: 2, ...user });
});
server.app.delete("/users/:id", (c) => {
c.status(204);
});
server.start();
});
it("should list users", async () => {
const response = await fetch(server.url("/users"));
const users = await response.json();
expect(response.status).toBe(200);
expect(users).toHaveLength(1);
});
it("should create user", async () => {
const response = await fetch(server.url("/users"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Bob" }),
});
const user = await response.json();
expect(user.name).toBe("Bob");
});
it("should delete user", async () => {
const response = await fetch(server.url("/users/1"), {
method: "DELETE",
});
expect(response.status).toBe(204);
await response.text(); // Consume body even if empty
});
Request Bodies
JSON:
it("should handle JSON body", async () => {
const response = await fetch(server.url("/api/data"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }),
});
const data = await response.json();
expect(data.key).toBe("value");
});
FormData:
it("should handle form data", async () => {
const formData = new FormData();
formData.append("name", "Alice");
formData.append("file", new Blob(["content"]), "test.txt");
const response = await fetch(server.url("/upload"), {
method: "POST",
body: formData,
});
expect(response.status).toBe(200);
await response.text();
});
Middleware
Test middleware by verifying its side effects (headers, context values, response modifications):
import type { MageMiddleware } from "@mage/app";
const requestCounter: MageMiddleware = async (c, next) => {
const count = c.get<number>("count") ?? 0;
c.set("count", count + 1);
await next();
};
beforeAll(() => {
server = new MageTestServer();
// Apply middleware globally
server.app.use(requestCounter);
server.app.get("/count", (c) => {
c.json({ count: c.get<number>("count") });
});
server.start();
});
it("should increment count via middleware", async () => {
const response = await fetch(server.url("/count"));
const data = await response.json();
expect(data.count).toBe(1);
});
Middleware chaining:
const addHeader: MageMiddleware = async (c, next) => {
await next();
c.header("X-Custom", "value");
};
const requireAuth: MageMiddleware = async (c, next) => {
if (!c.header("Authorization")) {
c.status(401);
c.text("Unauthorized");
return;
}
await next();
};
beforeAll(() => {
server = new MageTestServer();
server.app.get("/public", addHeader, (c) => {
c.text("public");
});
server.app.get("/protected", requireAuth, (c) => {
c.text("protected");
});
server.start();
});
it("should add custom header", async () => {
const response = await fetch(server.url("/public"));
expect(response.headers.get("X-Custom")).toBe("value");
await response.text();
});
it("should require auth", async () => {
const response = await fetch(server.url("/protected"));
expect(response.status).toBe(401);
await response.text();
});
it("should allow authenticated request", async () => {
const response = await fetch(server.url("/protected"), {
headers: { Authorization: "Bearer token" },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe("protected");
});
Input Validation
Valid input:
import { validator } from "@mage/app/validate";
import { z } from "zod";
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const { validate, valid } = validator(
{ json: userSchema },
{ reportErrors: true },
);
beforeAll(() => {
server = new MageTestServer();
server.app.post("/users", validate, (c) => {
const { json } = valid(c);
c.json({ id: 1, ...json });
});
server.start();
});
it("should accept valid user", async () => {
const response = await fetch(server.url("/users"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Alice",
email: "alice@example.com",
}),
});
expect(response.status).toBe(200);
const user = await response.json();
expect(user.name).toBe("Alice");
});
Invalid input:
it("should reject invalid user", async () => {
const response = await fetch(server.url("/users"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "",
email: "not-an-email",
}),
});
expect(response.status).toBe(400);
const error = await response.json();
expect(error.errors).toBeDefined();
});
Multiple validation sources:
const paramsSchema = z.object({
id: z.string().uuid(),
});
const bodySchema = z.object({
status: z.enum(["active", "inactive"]),
});
const { validate, valid } = validator({
params: paramsSchema,
json: bodySchema,
});
beforeAll(() => {
server = new MageTestServer();
server.app.patch("/users/:id", validate, (c) => {
const { params, json } = valid(c);
c.json({ id: params.id, status: json.status });
});
server.start();
});
it("should validate params and body", async () => {
const response = await fetch(
server.url("/users/550e8400-e29b-41d4-a716-446655440000"),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "active" }),
},
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBe("550e8400-e29b-41d4-a716-446655440000");
expect(data.status).toBe("active");
});
Resource Management
Critical: Always consume response bodies to prevent resource leaks. Deno tracks open resources and will fail tests if bodies aren't consumed.
Correct patterns:
// Reading the body
const response = await fetch(url);
expect(response.status).toBe(200);
await response.text(); // ✅ Body consumed
// Or cancel if you don't need the body
const response = await fetch(url);
expect(response.headers.get("Content-Type")).toBe("application/json");
await response.body?.cancel(); // ✅ Body cancelled
// For concurrent requests
const [response1, response2] = await Promise.all([fetch(url1), fetch(url2)]);
await Promise.all([response1, response2].map((r) => r.text())); // ✅ All consumed
Common mistake:
// ❌ BAD: Body not consumed
it("should return 200", async () => {
const response = await fetch(url);
expect(response.status).toBe(200);
// Missing: await response.text()
});
Cleanup
Always clean up servers and temporary resources in afterAll:
Server shutdown:
afterAll(async () => {
await server.shutdown();
await server.finished; // Wait for server to fully stop
});
Temporary files:
let tempFile: string;
beforeAll(async () => {
tempFile = await Deno.makeTempFile();
await Deno.writeTextFile(tempFile, "test content");
server = new MageTestServer();
server.app.get("/file", async (c) => {
const content = await Deno.readTextFile(tempFile);
c.text(content);
});
server.start();
});
afterAll(async () => {
await server.stop();
await Deno.remove(tempFile); // Clean up temp file
});
Temporary directories:
let tempDir: string;
beforeAll(async () => {
tempDir = await Deno.makeTempDir();
// ... setup
server.start();
});
afterAll(async () => {
await server.stop();
await Deno.remove(tempDir, { recursive: true }); // Clean up recursively
});
Summary
Testing Mage applications follows standard HTTP API testing patterns:
- Setup: Spin up ephemeral server with
port: 0 - Test: Make requests with
fetchand assert on responses - Cleanup: Always consume response bodies and shut down servers
That's it. No magic, no special utilities—just HTTP testing with clean resource management.