zudo-test-wisdom
GitHub repository

Type to search...

to open search from anywhere

Backend & Node.js Testing

Testing patterns for server-side Node.js code with Vitest -- Cloudflare Functions on Miniflare, HTTP API testing, fetch mocking with vi.stubGlobal, temp-directory file system testing, and the principles for separating frontend and backend test configs.

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. D1's exec() splits its input on newlines (one
  // statement per line), so a pretty-printed multi-statement migration
  // file throws D1_EXEC_ERROR -- split on `;`, collapse each statement
  // to a single line, and exec() one at a time instead.
  const migration = readFileSync("migrations/0001_init.sql", "utf-8");
  const db = env.DB as D1Database;
  const statements = migration
    .split(";")
    .map((statement) => statement.replace(/\s+/g, " ").trim())
    .filter(Boolean);
  for (const statement of statements) {
    await db.exec(statement);
  }

  return { mf, env, db, bucket: env.BUCKET as R2Bucket };
}

Warning

D1's exec() splits on newlines, not semicolons. Each line of the input string is executed as a separate statement, so passing a multi-line migration file straight through throws D1_EXEC_ERROR: incomplete input. Split the file on ; yourself, collapse each statement's whitespace to a single line, and call exec() once per statement -- this is a platform quirk, not something recoverable by reading more of your own code.

The split(";") above is a naive line-based splitter: it breaks on migrations with semicolons inside string literals, comments, or trigger bodies. For anything beyond simple CREATE TABLE/CREATE INDEX statements, use a real SQL statement splitter (for example @databases/split-sql-query) instead of a plain string split.

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
// Extends the real interface with the escape hatch tests use to seed and
// inspect state directly -- see "The inspectable-mock-store design" below.
export interface MockKVNamespace extends KVNamespace {
  _store: Map<string, string>;
}

export function createMockKV(): MockKVNamespace {
  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();
    },
    _store: store,
  } as unknown as MockKVNamespace;
}

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.

Real workerd Testing with vitest-pool-workers

Miniflare emulates the Workers runtime in Node. @cloudflare/vitest-pool-workers goes one step further: Vitest test files run inside an actual workerd isolate, with real D1/KV/R2 bindings wired through wrangler.toml rather than a Node-side emulation layer. Live practice: zudo-file-sync runs its in-worker suites this way.

// vitest.config.ts
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";

export default defineWorkersConfig({
  test: {
    poolOptions: {
      workers: {
        wrangler: { configPath: "./wrangler.toml" },
      },
    },
  },
});
// test/worker.test.ts -- runs inside the real workerd isolate
import { env } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("worker (pool-workers)", () => {
  it("reads and writes through the real D1 binding", async () => {
    await env.DB.exec("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)");
    await env.DB.prepare("INSERT INTO t (id) VALUES (1)").run();
    const row = await env.DB.prepare("SELECT * FROM t WHERE id = 1").first();
    expect(row).toMatchObject({ id: 1 });
  });
});

Tip

The decision rule, extended to a third option. Map-stub: fast, no platform semantics, for handler logic (above). Miniflare: binding behavior emulated in Node, for SQL/storage semantics (above). @cloudflare/vitest-pool-workers: reach for this only when the platform semantics themselves are under test -- D1 quirks that only reproduce in real workerd, waitUntil timing, bindings wiring, or an in-worker end-to-end path. It is the slowest and heaviest of the three; use it when Miniflare's own emulation is the thing in question, not your handler.

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() does not undo vi.stubGlobal, and it does not
    // clear fetchMock's queued mockResolvedValueOnce() calls -- both leak
    // into the next test unless cleared explicitly.
    vi.unstubAllGlobals();
    fetchMock.mockReset();
  });

  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, beforeEach, 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

CreatedUpdated