This guide explains how to run and write integration tests for API routes.
Integration tests for API routes are located in tests/integration/. They use Node.js's built-in test module to directly call route handlers with mock requests, asserting on status codes and response shapes.
Instead of spinning up a full Next.js server, we import route handlers directly and call them as functions. This approach:
- ✅ Fast: No server startup overhead
- ✅ Deterministic: No flaky timing issues
- ✅ Easy to Debug: Direct function calls are easier to trace
- ✅ Isolated: Tests don't interfere with each other
npm testnpm run test:integrationnpm run test:integration:watchnode --import tsx/esm --test tests/integration/health.test.cjsEach test file follows this pattern:
import test from "node:test";
import { setTestEnv, createMockRequest } from "./setup.js";
import { callHandler, expectStatus } from "./helpers.js";
import { GET } from "@/app/api/your/route/route.js";
// Set up environment
setTestEnv();
test("GET /api/your/route", async (t) => {
await t.test("should return 200 with expected response", async () => {
const request = createMockRequest("GET", "/api/your/route");
const response = await callHandler(GET, request);
expectStatus(response, 200);
// Add more assertions...
});
});Configures test environment (in-memory DB, session password, etc.). Call once per test file.
setTestEnv();Create a mock Request object for testing.
// Simple GET request
const request = createMockRequest("GET", "/api/health");
// POST with body
const request = createMockRequest("POST", "/api/auth/login", {
body: { address: "GTEST123", signature: "sig" },
});
// With headers
const request = createMockRequest("GET", "/api/split", {
headers: { "X-Custom-Header": "value" },
});
// With cookies
const request = createMockRequest("GET", "/api/split", {
cookies: { session_id: "abc123" },
});Create an encrypted session cookie for authenticated requests.
const session = await createMockSession(
"GBNRAKMZMK7VYN7YSMV2AVVQUHCZQPBJ6IYHGFAUWXRPQVGGFCZPCLVP",
);Create a request with a valid session cookie automatically.
const request = await createAuthenticatedRequest(
"GET",
"/api/split",
"GBNRAKMZMK7VYN7YSMV2AVVQUHCZQPBJ6IYHGFAUWXRPQVGGFCZPCLVP",
);Call a route handler with a request object.
import { GET } from "@/app/api/health/route.js";
const response = await callHandler(GET, request);Assert response status code.
expectStatus(response, 200);
expectStatus(response, 401);Parse and return response JSON. Throws if not valid JSON.
const body = await expectJson(response);
console.log(body);Assert status AND parse JSON in one call.
const body = await expectStatusJson(response, 200);Assert 401 response with error message.
await expectUnauthorized(response);Assert 400 response with error message.
await expectBadRequest(response);Assert object has a property (optionally with a specific value).
expectProperty(body, "status", "ok");
expectProperty(body, "nonce"); // Just checks it existsCreate tests/integration/your-route.test.ts:
import test from "node:test";
import {
setTestEnv,
createMockRequest,
createAuthenticatedRequest,
} from "./setup.js";
import { callHandler, expectStatus, expectStatusJson } from "./helpers.js";
import { GET, POST } from "@/app/api/your/route/route.js";
setTestEnv();
test("GET /api/your/route", async (t) => {
await t.test("should return 200 when valid", async () => {
const request = createMockRequest("GET", "/api/your/route");
const response = await callHandler(GET, request);
expectStatus(response, 200);
});
await t.test("should return 401 when unauthenticated", async () => {
const request = createMockRequest("GET", "/api/your/route");
const response = await callHandler(GET, request);
expectStatus(response, 401);
});
await t.test("should return 200 when authenticated", async () => {
const address = "GBNRAKMZMK7VYN7YSMV2AVVQUHCZQPBJ6IYHGFAUWXRPQVGGFCZPCLVP";
const request = await createAuthenticatedRequest(
"GET",
"/api/your/route",
address,
);
const response = await callHandler(GET, request);
const body = await expectStatusJson(response, 200);
// Validate response shape
if (typeof body !== "object" || body === null) {
throw new Error("Expected object response");
}
});
});Each route should test:
- ✅ Happy path (valid inputs, expected response)
- ✅ Auth required (401 if no session)
- ✅ Invalid inputs (400 for bad request)
- ✅ Response shape (expected fields present)
Prefer the helper assertions over raw assert:
// ✅ Good
expectStatus(response, 200);
const body = await expectJson(response);
expectProperty(body, "status", "ok");
// ❌ Avoid
assert.equal(response.status, 200);
const body = JSON.parse(await response.text());Test environment is automatically set by setTestEnv():
| Variable | Value | Purpose |
|---|---|---|
DATABASE_URL |
file::memory: |
In-memory SQLite (fast, isolated) |
SESSION_PASSWORD |
32-char string | Required for iron-session |
STELLAR_NETWORK |
testnet |
Stellar network selection |
TEST_MODE |
true |
Optional flag for test detection |
No additional setup needed.
await t.test("should return 401 without auth", async () => {
const request = createMockRequest("GET", "/api/protected");
const response = await callHandler(GET, request);
expectStatus(response, 401);
});
await t.test("should return 200 with auth", async () => {
const address = "GBNRAKMZMK7VYN7YSMV2AVVQUHCZQPBJ6IYHGFAUWXRPQVGGFCZPCLVP";
const request = await createAuthenticatedRequest(
"GET",
"/api/protected",
address,
);
const response = await callHandler(GET, request);
const body = await expectStatusJson(response, 200);
});await t.test("should reject invalid input", async () => {
const request = createMockRequest("POST", "/api/some/route", {
body: { invalid: "data" },
});
const response = await callHandler(POST, request);
await expectBadRequest(response);
});
await t.test("should accept valid input", async () => {
const request = createMockRequest("POST", "/api/some/route", {
body: { required: "value", count: 5 },
});
const response = await callHandler(POST, request);
const body = await expectStatusJson(response, 200);
});await t.test("should have expected fields", async () => {
const request = createMockRequest("GET", "/api/data");
const response = await callHandler(GET, request);
const body = await expectStatusJson(response, 200);
// Check structure
expectProperty(body, "id");
expectProperty(body, "name");
expectProperty(body, "createdAt");
});node --test --verbose tests/integration/health.test.tsWrap other tests with .test.skip:
test("should not run", { skip: true }, async () => {});
test("should run", async () => {});import { setTestEnv, createMockRequest } from "./setup.js";
setTestEnv();
const request = createMockRequest("GET", "/api/test");
console.log("Request:", request);
const response = await callHandler(GET, request);
console.log("Response status:", response.status);The current test suite covers:
- ✅ Health check (no auth required)
- ✅ Auth routes (nonce, login, logout)
- ✅ Protected routes (401 without session, 200 with session)
- ✅ Validation errors (400, 401 responses)
- ✅ Response shapes (expected fields)
Tests run in GitHub Actions with:
- name: Run Integration Tests
run: npm run test:integrationRequired environment variables in CI:
DATABASE_URL- Auto-set to:memory:by testsSESSION_PASSWORD- Auto-set by tests- No external dependencies needed
- Tests are fast (<10 seconds) and deterministic
Make sure test files are in tests/integration/ and end with .test.ts:
tests/integration/your-route.test.ts ✅
tests/integration/your-route.ts ❌
tests/your-route.test.ts ❌
Use explicit .js extensions in imports (even for .ts files):
import { setTestEnv } from "./setup.js"; // ✅
import { setTestEnv } from "./setup"; // ❌The in-memory database is isolated per test file. Each run gets a fresh DB. No persistent database needed.
Make sure SESSION_PASSWORD is at least 32 characters:
// In setup.ts - already correct
process.env.SESSION_PASSWORD = "test-session-password-min-32-chars!!!!";To add more tests:
- Create
tests/integration/new-route.test.ts - Import route handler(s)
- Call
setTestEnv()at module level - Write test cases using helpers
- Run
npm run test:integration
Example routes to test next:
GET /api/billsPOST /api/goalsGET /api/insurancePOST /api/send