Backend & Node.js Testing
Testing patterns for server-side Node.js code with Vitest.
Frontend-Backend Separation Philosophy
The author’s approach: strong in frontend, delegate backend implementation to AI. This makes testing strategy critical — when you did not write the backend code yourself, tests are your primary verification that it works correctly.
The key principle is separation of concerns enables testable backends. When frontend and backend are properly separated:
- Each layer can be tested independently
- Frontend tests use MSW-like mocks to decouple from backend availability
- Backend tests run against real or emulated infrastructure without needing a browser
- Changes to one layer do not break the other layer’s tests
This separation is not just an architectural nicety — it is what makes AI-assisted backend development viable. The AI writes the implementation, and the tests verify it actually works.
Cloudflare Functions with Miniflare
For projects using Cloudflare Workers/Functions with D1 (SQLite) databases and R2 object storage, Miniflare provides local emulation for integration testing.
Test Environment Setup
Create a helper that spins up an isolated test environment with real D1 and R2 bindings:
// test/helpers/test-env.ts
import { Miniflare } from "miniflare";
import { readFileSync } from "fs";
export async function createTestEnv() {
const mf = new Miniflare({
modules: true,
script: "",
d1Databases: ["DB"],
r2Buckets: ["BUCKET"],
});
const env = await mf.getBindings();
// Run SQL migrations
const migration = readFileSync("migrations/0001_init.sql", "utf-8");
const db = env.DB as D1Database;
await db.exec(migration);
return { mf, env, db, bucket: env.BUCKET as R2Bucket };
}
Test Data Factories
Use factory functions to create test data with sensible defaults:
// test/helpers/factories.ts
export function createTestProject(overrides: Partial<Project> = {}): Project {
return {
id: crypto.randomUUID(),
name: "Test Project",
createdAt: new Date().toISOString(),
...overrides,
};
}
Full CRUD Lifecycle Tests
Test the complete create-read-update-delete cycle against the emulated infrastructure:
import { describe, it, expect, beforeEach } from "vitest";
import { app } from "../src/index";
import { createTestEnv } from "./helpers/test-env";
describe("Projects API", () => {
let env: any;
let mf: any;
beforeEach(async () => {
({ env, mf } = await createTestEnv());
});
it("creates and retrieves a project", async () => {
const createRes = await app.request(
new Request("http://localhost/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "My Project" }),
}),
{},
env,
);
expect(createRes.status).toBe(201);
const created = await createRes.json();
const getRes = await app.request(
new Request(`http://localhost/api/projects/${created.id}`),
{},
env,
);
expect(getRes.status).toBe(200);
const fetched = await getRes.json();
expect(fetched.name).toBe("My Project");
});
});
The key pattern here is app.request(req, {}, env) — this calls the Hono app directly with the Miniflare-provided bindings, bypassing HTTP entirely.
Separate Vitest Config
Backend tests need their own Vitest config with environment: 'node':
// vitest.config.backend.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/backend/**/*.test.ts"],
testTimeout: 10000,
},
});
Mock the Binding, Not the Runtime
The Miniflare section above runs a real emulated runtime. That is the right call when you are testing storage semantics — but it is the wrong call when you are testing your own handler logic. The opposite choice is to fake a Cloudflare binding’s public interface with an in-memory Map and never start Miniflare at all.
A Cloudflare binding like KVNamespace is just an object with a handful of methods. You can hand-roll one backed by a Map, implement only the methods under test, and cast it with as unknown as KVNamespace:
// test/helpers/mock-kv.ts
export function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get(key: string): Promise<string | null> {
return Promise.resolve(store.get(key) ?? null);
},
put(key: string, value: string): Promise<void> {
store.set(key, value);
return Promise.resolve();
},
delete(key: string): Promise<void> {
store.delete(key);
return Promise.resolve();
},
} as unknown as KVNamespace;
}
The same trick works for R2Bucket (get/put/delete over a Map<string, ArrayBuffer>) and for D1Database. The D1 case is the most instructive: you do not need a SQL engine, only a tiny dispatcher keyed on a substring of the query.
// test/helpers/mock-d1.ts
interface ProductRow {
id: number;
name: string;
}
export function createMockD1(rows: ProductRow[]): D1Database {
function makeStatement(sql: string, params: unknown[] = []) {
return {
bind(...args: unknown[]) {
return makeStatement(sql, args);
},
async first() {
if (/where id = \?/i.test(sql)) {
return rows.find((r) => r.id === params[0]) ?? null;
}
return rows[0] ?? null;
},
async all() {
if (/from products/i.test(sql)) {
return { results: rows, success: true, meta: {} };
}
return { results: [], success: true, meta: {} };
},
async run() {
return { success: true, meta: { changes: 1 } };
},
};
}
return {
prepare(sql: string) {
return makeStatement(sql);
},
// a real miniflare D1 would test miniflare's SQLite, not this adapter's threading.
} as unknown as D1Database;
}
The comment in that stub is the whole point. A test built on this stub asserts that your handler threads the binding correctly and branches on the right rows — it does not, and should not, assert anything about how SQLite executes a WHERE clause. Pushing real SQL through Miniflare here would test Miniflare’s SQLite engine, not your code.
Handler tests then assemble env from a plain factory and call the app directly:
import { describe, it, expect } from "vitest";
import { app } from "../src/index";
import { createMockKV } from "./helpers/mock-kv";
function createEnv(kv: KVNamespace) {
return { KV: kv } as Env;
}
describe("session handler (Map-stub)", () => {
it("returns 200 when the session exists", async () => {
const kv = createMockKV();
await kv.put("session:abc", JSON.stringify({ userId: 1 }));
const res = await app.request(
new Request("http://localhost/api/me", {
headers: { Cookie: "sid=abc" },
}),
{},
createEnv(kv),
);
expect(res.status).toBe(200);
});
});
💡 Tip
The decision rule — this is the gap. Use Miniflare when you are testing SQL or storage semantics (does this query return the right rows, does this R2 multipart upload reassemble correctly). Use a Map-stub when you are testing your handler’s branching and threading (does this route read the binding it should, does it return 401 when the session is missing). The load-bearing principle: test YOUR logic, not the platform’s storage engine. Map-stubs are faster, need no wrangler.toml, run in plain Node, and let you pre-seed exact state.
Testing Time-Bucketed Logic
Rate limiters, sliding windows, TTL caches, and JWT expiry all share one problem for tests: their behavior depends on the current time, and the boundary — the exact instant a counter resets or a token expires — is the part most likely to be wrong. Sleeping in a test to cross that boundary is slow and flaky. Freeze the clock instead.
The recipe is always the same: freeze time, derive the bucket key from the frozen now, pre-load the store to the threshold, then assert the boundary.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { checkRateLimit } from "../rate-limit";
import { createMockKV } from "./helpers/mock-kv";
describe("checkRateLimit", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-18T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("blocks the 60th request in the same minute", async () => {
const kv = createMockKV();
const env = { RATE_LIMIT: kv, _kv: kv } as MockEnv;
// Derive the SAME bucket key the production code computes.
const now = Date.now();
const minBucket = Math.floor(now / 60_000);
// Pre-seed 59 prior requests this minute via the inspectable store.
env._kv._store.set(`rate:min:testhash:${minBucket}`, "59");
const result = await checkRateLimit("testhash", env);
expect(result.allowed).toBe(true);
env._kv._store.set(`rate:min:testhash:${minBucket}`, "60");
const blocked = await checkRateLimit("testhash", env);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
});
Two pieces make this work. First, the test computes minBucket with the exact same Math.floor(now / 60_000) formula the production code uses — because the clock is frozen, both sides land in the identical bucket, so the pre-seeded key is the one the code will read. Second, the mock KV deliberately exposes its internal Map as _store so the test can write state directly, without going through put.
ℹ️ Info
The inspectable-mock-store design. A production-faithful mock would hide its internal Map. A test mock should do the opposite: expose _store (or _kv._store) as an escape hatch so tests can arbitrage state directly — seed exactly 59 entries, inspect what the handler wrote, or corrupt a value to test recovery. This is the reusable pattern behind every windowed-counter, TTL, and sliding-window test: you do not wait for the window to fill, you place the store at the threshold and assert across the boundary.
The same frozen-clock technique covers JWT expiry. Because vi.setSystemTime controls what Date.now() returns, you can mint a token with a known exp and assert it is accepted just before that instant and rejected just after:
it("accepts a token before exp and rejects it after", async () => {
vi.setSystemTime(new Date("2026-03-18T12:00:00.000Z"));
const token = await createToken({ sub: "user-1" }, TEST_SECRET, "1h");
// Still inside the hour: valid.
vi.setSystemTime(new Date("2026-03-18T12:59:00.000Z"));
await expect(verifyToken(token, TEST_SECRET)).resolves.toMatchObject({
sub: "user-1",
});
// One second past exp: rejected.
vi.setSystemTime(new Date("2026-03-18T13:00:01.000Z"));
await expect(verifyToken(token, TEST_SECRET)).rejects.toThrow();
});
📝 Note
This covers only the time-based expiry edge of JWT testing. Asserting a fixed signature against a hardcoded secret and payload (known-answer vectors) is a different concern that belongs with crypto testing, not here.
HTTP API Testing
For testing against live or deployed endpoints (staging, preview, production), use direct HTTP requests.
Environment-Based URL Switching
// test/helpers/api-client.ts
function getBaseUrl(): string {
if (process.env.TEST_API_URL) {
return process.env.TEST_API_URL;
}
if (process.env.CF_PAGES_URL) {
return process.env.CF_PAGES_URL;
}
return "http://localhost:8787";
}
const BASE_URL = getBaseUrl();
export async function apiGet(path: string) {
return fetch(`${BASE_URL}${path}`, {
headers: {
Authorization: `Bearer ${process.env.TEST_API_TOKEN}`,
},
});
}
Destructive Test Guards
Tests that modify data should be skipped in production:
const isProduction = process.env.TEST_ENV === "production";
describe("Admin API", () => {
it.skipIf(isProduction)("deletes all test data", async () => {
const res = await apiGet("/api/admin/reset-test-data");
expect(res.status).toBe(200);
});
});
Network Timeouts
HTTP tests need longer timeouts than unit tests:
// vitest.config.http.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/http/**/*.test.ts"],
testTimeout: 30000,
},
});
HTTP Client Testing with Mocks
When testing code that makes HTTP requests (API clients, auth flows), mock fetch at the global level.
The vi.stubGlobal Pattern
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("ApiClient", () => {
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("sends auth header", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
const client = new ApiClient({ token: "test-token" });
await client.get("/api/data");
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/data"),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-token",
}),
}),
);
});
});
Auth Flow Testing
Test token refresh and retry logic by chaining mock responses:
it("retries with refreshed token on 401", async () => {
// First call returns 401
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }));
// Token refresh succeeds
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ token: "new-token" }), { status: 200 }),
);
// Retry with new token succeeds
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ data: "success" }), { status: 200 }),
);
const client = new ApiClient({ token: "old-token" });
const result = await client.get("/api/data");
expect(result.data).toBe("success");
expect(fetchMock).toHaveBeenCalledTimes(3);
});
File System Testing
For Node.js tools that read/write files, use temporary directories for isolation.
Temp Directory Pattern
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
describe("FileProcessor", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("processes and writes output file", () => {
const inputPath = join(tempDir, "input.txt");
const outputPath = join(tempDir, "output.txt");
writeFileSync(inputPath, "hello world");
processFile(inputPath, outputPath);
expect(readFileSync(outputPath, "utf-8")).toBe("HELLO WORLD");
});
});
The key points:
mkdtempSynccreates a unique directory per test run — no collisionsafterEachcleanup ensures no leftover files between tests- All paths are relative to
tempDir— tests never touch the real filesystem
Post-Deploy Smoke Testing with Shell Scripts
For Cloudflare Workers (or any deployed API), a shell-based smoke test provides fast, dependency-free verification of the full API flow. The test hits real endpoints, creates test data, asserts results, and cleans up on exit.
Self-Cleaning Test Script
The critical pattern is a trap EXIT that deletes test data even if the script fails mid-run:
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:?Usage: smoke-test.sh <server-url>}"
API="$BASE_URL/api/v1"
PASS=0
FAIL=0
# Track resources for cleanup
_AUTH=""
_VAULT_ID=""
cleanup() {
if [ -n "$_VAULT_ID" ] && [ -n "$_AUTH" ]; then
curl -sf -X DELETE -H "$_AUTH" "$API/vaults/$_VAULT_ID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
# --- assertions ---
assert_eq() {
local label="$1" actual="$2" expected="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $label"
PASS=$((PASS + 1)) # NOT ((PASS++)) -- see gotcha below
else
echo " FAIL: $label (expected '$expected', got '$actual')"
FAIL=$((FAIL + 1))
fi
}
⚠️ Warning
Bash gotcha with set -e and arithmetic: ((PASS++)) when PASS=0 evaluates 0++ which returns 0 (falsy), causing set -e to exit the script. Use PASS=$((PASS + 1)) instead.
Dual E2E Strategy: Local First, Remote After Deploy
The same smoke test script accepts a URL argument, enabling two modes:
{
"scripts": {
"test": "vitest run",
"test:e2e": "bash scripts/smoke-test.sh https://my-worker.account.workers.dev",
"test:e2e:local": "bash scripts/smoke-test-local.sh"
}
}
Development workflow: local first (fast feedback), remote after deploy (production verification).
The local wrapper starts wrangler dev, waits for readiness, runs the smoke test, then tears down:
#!/usr/bin/env bash
set -euo pipefail
PORT=8799
cd "$(dirname "$0")/.."
# Apply local D1 migration
wrangler d1 execute sync-db --local --file=migrations/0001_init.sql 2>/dev/null
# Write local secrets (wrangler uses .dev.vars, NOT shell env vars)
cat > .dev.vars.e2e <<'VARS'
DEV_MODE=true
JWT_SECRET=test-secret-for-local-e2e
VARS
wrangler dev --port "$PORT" --local --env-file .dev.vars.e2e 2>/dev/null &
DEV_PID=$!
cleanup() {
kill "$DEV_PID" 2>/dev/null || true
wait "$DEV_PID" 2>/dev/null || true
rm -f .dev.vars.e2e
}
trap cleanup EXIT
# Wait for server readiness
for i in $(seq 1 30); do
curl -sf "http://localhost:$PORT/health" >/dev/null 2>&1 && break
[ "$i" -eq 30 ] && { echo "Server did not start"; exit 1; }
sleep 1
done
# Reuse the same smoke test
scripts/smoke-test.sh "http://localhost:$PORT"
💡 Tip
Wrangler secrets in local dev: Wrangler does NOT read shell environment variables as Worker bindings. Use a .dev.vars file or the --env-file flag. This is a common gotcha when transitioning from remote secrets (wrangler secret put) to local development.
CI Integration: Post-Deploy Verification
Add the smoke test as a post-deploy step in GitHub Actions:
- name: Deploy to Cloudflare Workers
run: cd workers/sync-server && pnpm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Post-deploy smoke test
run: cd workers/sync-server && pnpm run test:e2e
This catches deployment issues that unit tests cannot: misconfigured secrets, missing D1 migrations, R2 bucket permissions, and Durable Object binding errors.
Three-Tier Backend Testing Strategy
| Tier | Tool | Scope | When |
|---|---|---|---|
| Unit | Vitest + Miniflare | Individual handlers, in-process | Every push (CI) |
| Local E2E | wrangler dev + shell script | Full HTTP flow, local D1/R2 | During development |
| Remote E2E | curl + shell script | Deployed infra, real bindings | Post-deploy (CI) |
Unit tests catch logic bugs. Local E2E catches integration bugs. Remote E2E catches infrastructure bugs. Each tier covers a different failure class.
Key Principles for Backend Testing
-
Use
environment: 'node'in vitest config, notjsdom. Backend code does not need a DOM. -
Separate vitest configs for frontend and backend tests. They need different environments, different timeouts, and often different setup files.
-
Helper factories for test data. Never hardcode test data inline — use factory functions with sensible defaults and overrides.
-
Environment variables for configuration switching. Use
process.envto switch between local, preview, and production URLs rather than hardcoding. -
Guard destructive tests with
it.skipIf(). Tests that delete data or reset state should never run against production. -
Longer timeouts for network tests. Default 5-second timeouts are too short for HTTP integration tests. Use 30 seconds or more.