zudo-test-wisdom

Type to search...

to open search from anywhere

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:

  • mkdtempSync creates a unique directory per test run — no collisions
  • afterEach cleanup 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

TierToolScopeWhen
UnitVitest + MiniflareIndividual handlers, in-processEvery push (CI)
Local E2Ewrangler dev + shell scriptFull HTTP flow, local D1/R2During development
Remote E2Ecurl + shell scriptDeployed infra, real bindingsPost-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

  1. Use environment: 'node' in vitest config, not jsdom. Backend code does not need a DOM.

  2. Separate vitest configs for frontend and backend tests. They need different environments, different timeouts, and often different setup files.

  3. Helper factories for test data. Never hardcode test data inline — use factory functions with sensible defaults and overrides.

  4. Environment variables for configuration switching. Use process.env to switch between local, preview, and production URLs rather than hardcoding.

  5. Guard destructive tests with it.skipIf(). Tests that delete data or reset state should never run against production.

  6. Longer timeouts for network tests. Default 5-second timeouts are too short for HTTP integration tests. Use 30 seconds or more.

Revision History