Testing and Local Emulation for Edge Runtimes

This guide is part of Edge Runtime Fundamentals & Platform Constraints. It lays out how to split edge middleware tests across three tiers — pure handler tests, integration runs inside a real edge sandbox, and smoke tests against a deployed preview — and, just as importantly, which production behaviours no local emulator will ever show you.

Testing server code used to be a solved problem: spin up the framework, hit it with a fake request, assert on the response. Edge middleware breaks that comfortable assumption in two directions at once. The code runs in a runtime that is not Node, so half your testing toolchain either fails to load or silently behaves differently. And the code’s correctness depends on properties the runtime enforces — a CPU budget, an immutable header guard, an eventually consistent key-value store, a geolocation field injected by the network — none of which a plain unit test can create.

The result is a specific failure pattern that almost every team hits. The suite is green, the preview deploy looks fine, and then production throws TypeError: Can't modify immutable headers on a code path that ran thousands of times locally without complaint. Or a middleware that took 3 ms in tests gets killed at 50 ms of CPU under real traffic because the fixture body was 200 bytes and the real one is 2 MB. The tests were not wrong; they were testing a runtime that does not exist.

The constraint that shapes the whole strategy: fidelity costs time

Every testing decision at the edge is a trade between how faithfully the environment reproduces production and how fast you get an answer. A pure function test that constructs a Request, calls your handler, and inspects the returned Response runs in under a millisecond in Node — but Node is not workerd, request.headers in Node’s undici is mutable where production’s is not, and there is no env object at all. An integration test running inside real workerd via @cloudflare/vitest-pool-workers gets you the actual runtime, the actual header guards, and actual KV and Durable Object bindings — at a few hundred milliseconds of startup and a heavier dependency footprint. A smoke test against a real preview URL gets you the real network, real TLS, real geolocation and real cold starts — at seconds per case and a deployment you have to wait for.

You cannot pick one. Pure tests are fast enough to run on every keystroke but blind to runtime enforcement. Sandbox tests catch runtime enforcement but still fake the network. Smoke tests catch the network but are too slow and too flaky to carry your whole correctness story. The pyramid exists because each tier catches a class of bug the tier below it structurally cannot see.

The edge testing pyramid Three stacked bands widening downward. The narrow top band is preview smoke tests, the middle band is integration tests inside workerd, and the wide base is pure unit tests of handlers against Request and Response. Smoke tests real preview URL, real network 3 to 8 cases, seconds each Integration tests in workerd real bindings, real header guards, real Cache API 20 to 60 cases, sub-second each Unit tests: Request in, Response out routing, claim parsing, header construction, early-return branches hundreds of cases, milliseconds total Fidelity rises upward; feedback speed rises downward. Neither direction is optional.
Each tier exists because the one below it is structurally blind to a class of failure — not because more tests are better.

What local emulators actually reproduce

Modern edge emulators are far better than the “mock the framework” era suggests. wrangler dev runs workerd, the same C++ runtime that executes your Worker in production, compiled for your laptop. Miniflare, which underpins it and @cloudflare/vitest-pool-workers, provides local implementations of KV, R2, D1, Durable Objects, queues and the Cache API. next dev runs Vercel Edge Middleware in a constrained Node vm that blocks Node built-ins. Netlify’s CLI runs Edge Functions in a real Deno process.

That means a large and important set of behaviours is faithfully reproduced. The Web API surface is the same, so URLPattern, TextEncoder, crypto.subtle and the streams APIs behave identically — see supported Web APIs in edge runtimes for the full inventory. Missing Node built-ins fail the same way locally as in production, which is exactly what you want: a bundle that imports path should break on your machine, not on deploy. Header immutability guards are enforced by workerd locally, so the Can't modify immutable headers error surfaces in an integration test. Response streaming semantics, ReadableStream backpressure, and HTMLRewriter all run for real.

What emulators do not reproduce is anything the platform enforces at the network or scheduler level rather than inside the isolate. That list is short but it is where production incidents live.

CPU and wall-clock limits are not enforced locally. wrangler dev will happily let a handler burn two seconds of CPU. Production kills it at the plan’s ceiling. A regex with catastrophic backtracking, a JSON parse of an unexpectedly large body, a loop over a KV list — all pass locally and fail under real payloads. The mitigations are covered in avoiding CPU time limit errors in Cloudflare Workers.

Geolocation fields are stubbed or absent. request.cf locally returns a fixed placeholder object; Vercel’s request.geo is empty or synthesised; Netlify’s context.geo returns your own resolved location, if anything. Any branch keyed on country, region or timezone is effectively untested unless you inject the value.

KV reads are strongly consistent locally. Miniflare’s KV is a local store: write then read and you get your write back immediately. Production KV is eventually consistent, with propagation typically measured in seconds and a hard ceiling around a minute in the worst case. Every read-after-write bug in KV-backed session or feature-flag code is invisible locally by construction. This is the single most dangerous divergence, and it is why handling KV eventual consistency in edge reads is a design problem rather than a testing problem.

Cache tiers do not exist. Locally there is one cache: yours. Production has a PoP-local cache, possibly an upper tier, and a global network. A cache key bug that produces a hit locally can produce a permanent miss in production because the second request landed on a different PoP. Tiered behaviour is only observable in a deployed environment.

Cold starts do not happen. Your local isolate is created once and reused for the whole session, so module-scope initialisation runs once and everything after is warm. Production creates and destroys isolates constantly, so module-scope work runs far more often than local timing suggests, and any accidental per-isolate network fetch multiplies.

TLS, Secure cookies and HTTP semantics differ. Over http://localhost a Secure cookie may be silently dropped by the browser, __Host- prefixed cookies may not be set, and there is no HTTP/2 or HTTP/3 to expose header-casing or trailer differences.

Emulation fidelity matrix Seven production behaviours listed as rows against three columns for local emulation, remote-bound emulation, and deployed production. Green cells mean faithful, amber means partial, red means not reproduced. Behaviour local remote bindings deployed Web API surface Immutable header guards CPU and wall-clock limits KV eventual consistency Geolocation fields Cache tiers and PoP spread Cold starts and isolate churn TLS and Secure cookies
Green cells are safe to test locally. Every red cell is a bug class that can only be found in a deployed environment, so it needs a smoke test rather than more unit tests.

Architecture overview: shape the code so it is testable at the cheapest tier

The pyramid only works if most of your logic can be asserted at the base. That is a code-structure decision, not a test-tooling decision. Middleware that reaches directly into env.SESSIONS.get() halfway through a routing branch forces every test of that branch into the sandbox tier. Middleware that computes a decision from a Request and then applies it can put the decision under hundreds of fast tests and the application under a handful of slow ones.

The pattern is a two-phase handler: a pure decide function that maps an input snapshot to a typed decision, and a thin apply function that performs the effects. The snapshot is deliberately plain data, so tests never construct fake bindings.

// lib/policy.ts — pure, no bindings, no fetch, no globals.
export interface RequestSnapshot {
  method: string;
  pathname: string;
  search: string;
  country: string | null;
  cookies: Record<string, string>;
  hasBearer: boolean;
}

export type Decision =
  | { kind: 'pass'; setHeaders: Record<string, string> }
  | { kind: 'redirect'; location: string; status: 302 | 307 | 308 }
  | { kind: 'reject'; status: 401 | 403 | 429; reason: string }
  | { kind: 'rewrite'; pathname: string };

export function decide(s: RequestSnapshot): Decision {
  if (s.pathname.startsWith('/_next/') || s.pathname === '/favicon.ico') {
    return { kind: 'pass', setHeaders: {} };
  }
  if (s.pathname.startsWith('/admin') && !s.hasBearer) {
    return { kind: 'reject', status: 401, reason: 'admin_no_credential' };
  }
  if (s.pathname === '/' && s.country && s.country !== 'US') {
    return { kind: 'redirect', location: `/${s.country.toLowerCase()}/`, status: 307 };
  }
  const variant = s.cookies['ab'] ?? 'control';
  return { kind: 'pass', setHeaders: { 'x-variant': variant } };
}

export function snapshot(request: Request, country: string | null): RequestSnapshot {
  const url = new URL(request.url);
  return {
    method: request.method,
    pathname: url.pathname,
    search: url.search,
    country,
    cookies: parseCookies(request.headers.get('cookie')),
    hasBearer: (request.headers.get('authorization') ?? '').startsWith('Bearer '),
  };
}

function parseCookies(header: string | null): Record<string, string> {
  const out: Record<string, string> = {};
  for (const part of (header ?? '').split(';')) {
    const eq = part.indexOf('=');
    if (eq > 0) out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
  }
  return out;
}

decide is a pure function over a plain object. Every routing rule, every precedence question, every early-return branch is now a table-driven test that runs in microseconds and needs no runtime at all. The apply half — building the Response, cloning headers, writing to KV — is where the runtime actually matters, and it is small enough to cover exhaustively in the sandbox tier.

This split also fixes the biggest correctness trap in edge middleware, which is that the header objects you get are frozen. request.headers is guarded, and a Response returned by fetch() has immutable headers in Workers. Mutating them throws. The idiomatic fix is to construct new objects rather than mutate borrowed ones:

// lib/apply.ts
export function apply(request: Request, d: Decision): Request | Response {
  switch (d.kind) {
    case 'reject':
      return new Response(null, {
        status: d.status,
        headers: { 'cache-control': 'no-store', 'x-deny-reason': d.reason },
      });
    case 'redirect':
      return new Response(null, { status: d.status, headers: { location: d.location } });
    case 'rewrite': {
      const url = new URL(request.url);
      url.pathname = d.pathname;
      return new Request(url, request);
    }
    case 'pass': {
      // Copy, never mutate: request.headers is immutable.
      const headers = new Headers(request.headers);
      for (const [k, v] of Object.entries(d.setHeaders)) headers.set(k, v);
      return new Request(request, { headers });
    }
  }
}

A pure-Node unit test will not catch a violation of that rule, because Node’s Headers implementation has no guard. A workerd integration test will. That is precisely the division of labour the pyramid is describing.

Test doubles for KV and Durable Objects

Below the sandbox tier you need substitutes for bindings. Resist the urge to reach for a mocking framework; a hand-written in-memory object that satisfies the narrow slice of the interface you actually use is smaller, faster and far easier to reason about.

// test/doubles.ts
type KVValue = { value: string; expiresAt: number | null };

export interface FakeKVOptions {
  /** Milliseconds a written value stays invisible to reads, simulating propagation. */
  propagationDelayMs?: number;
  now?: () => number;
}

export function fakeKV(opts: FakeKVOptions = {}) {
  const now = opts.now ?? (() => Date.now());
  const delay = opts.propagationDelayMs ?? 0;
  const store = new Map<string, KVValue & { visibleAt: number }>();
  const calls: Array<{ op: 'get' | 'put' | 'delete'; key: string }> = [];

  return {
    calls,
    async get(key: string): Promise<string | null> {
      calls.push({ op: 'get', key });
      const entry = store.get(key);
      if (!entry) return null;
      if (now() < entry.visibleAt) return null;              // not yet propagated
      if (entry.expiresAt !== null && now() > entry.expiresAt) return null;
      return entry.value;
    },
    async put(key: string, value: string, o?: { expirationTtl?: number }): Promise<void> {
      calls.push({ op: 'put', key });
      store.set(key, {
        value,
        expiresAt: o?.expirationTtl ? now() + o.expirationTtl * 1000 : null,
        visibleAt: now() + delay,
      });
    },
    async delete(key: string): Promise<void> {
      calls.push({ op: 'delete', key });
      store.delete(key);
    },
  };
}

The propagationDelayMs option is the point of the whole double. Set it to zero and you have a strongly consistent store that mirrors Miniflare. Set it to 5000 and your read-after-write path is forced to prove it degrades correctly rather than returning null and treating that as “logged out”. Two tests over the same code — one consistent, one lagging — encode the real production contract far better than any amount of local wrangler dev poking.

Durable Objects need a different double because their whole value is serialised, single-instance execution. A useful stub exposes the same idFromName / get / fetch shape and routes to an in-process object, letting you assert on ordering:

// test/doubles.ts (continued)
export function fakeDurableNamespace(handler: (state: Map<string, unknown>, req: Request) => Promise<Response>) {
  const instances = new Map<string, Map<string, unknown>>();
  return {
    idFromName: (name: string) => ({ name, toString: () => name }),
    get(id: { name: string }) {
      const state = instances.get(id.name) ?? new Map<string, unknown>();
      instances.set(id.name, state);
      return { fetch: (req: Request) => handler(state, req) };
    },
  };
}

For anything where the concurrency semantics are the thing under test — a sliding-window limiter, a lock, a counter under contention — the double is not enough and the test belongs in the sandbox tier with a real Durable Object. Doubles are for the code around the binding, not for the binding’s own guarantees.

Provider mapping

Concern Cloudflare Workers Vercel Edge Middleware Netlify Edge Functions
Local runtime wrangler dev running real workerd via Miniflare next dev / vercel dev in a Node vm with Node built-ins blocked netlify dev running a real Deno process
In-process test runner @cloudflare/vitest-pool-workers executes tests inside workerd Vitest with environment: 'edge-runtime' (Node-hosted approximation) deno test, or Vitest against the exported handler
Bindings locally Full local KV, R2, D1, Durable Objects, Queues; --remote binds to real resources Edge Config and KV read through the real service with a token; no local emulation Netlify Blobs emulated locally; environment injected by the CLI
Geolocation locally request.cf returns a fixed placeholder object request.geo empty unless overridden by header spoofing context.geo resolves from your own IP
CPU limit enforced locally No — production ceiling is 10 ms on the free tier, up to 30 s of CPU on paid No — production is roughly 50 ms of CPU per invocation No — production wall limit is roughly 50 ms before response start
Memory ceiling 128 MB per isolate 128 MB 512 MB per Deno isolate
Cache API locally Emulated single-tier caches.default; no tiered cache caches unavailable in middleware; caching is header-driven Deno caches available; CDN tiering not emulated
Secrets locally .dev.vars file, never committed .env.local pulled with vercel env pull .env plus netlify env:import

Two rows deserve emphasis. Cloudflare is the only one of the three that runs the actual production runtime on your laptop and inside your test process; that is why its sandbox tier is meaningfully higher fidelity than the others, and it is a real factor when choosing between Vercel Edge and Cloudflare Workers. And no provider enforces its CPU ceiling locally, which means budget regressions must be caught by measurement in a deployed environment, never by a green test suite.

Control-flow variants

Table-driven routing tests. Because decide is pure, the highest-value test in the whole suite is a single table listing input paths and expected decision kinds. New route, new row. This is where matcher-precedence bugs surface, the kind covered in controlling matcher order in Next.js middleware.

Early-return assertions. An early return is only correct if it is both taken and terminal — the request must not continue down the chain. Assert on both: the returned status, and that no downstream effect ran. With a fake binding that records calls, expect(kv.calls).toHaveLength(0) is the assertion that actually proves short-circuiting, and it is far stronger than checking the status alone. See implementing early returns in edge middleware for the patterns being verified.

Background-work assertions. ctx.waitUntil accepts a promise the platform keeps alive after the response is sent. In a test, a fake ctx that pushes promises into an array lets you assert both that background work was scheduled and that it eventually resolved, without making the assertion racy.

Degradation paths. For every dependency, there should be a test where it fails: KV returns null, the upstream fetch rejects, the token endpoint times out. The assertion is not “it throws” but “it returns the intended fallback response”. A middleware that throws returns a 500 for the whole route, which is usually worse than the degraded behaviour you intended.

Choosing the tier for a new test A left-to-right decision tree. A new assertion that needs no binding becomes a unit test. One needing a binding but no network reality becomes a workerd integration test. One needing geolocation, TLS, or real CPU limits becomes a preview smoke test. New assertion where does it go? Needs a binding or a runtime guard? Unit test pure decide(), no runtime Needs geo, TLS, tiers or a real CPU ceiling? Integration in workerd vitest-pool-workers Smoke test deployed preview URL no yes no yes
Most assertions should exit this tree on the first branch. If they do not, the code usually wants restructuring more than the test does.

Framework integration notes

Next.js App Router. middleware.ts exports a default function and a config object. Import the default export directly in Vitest and call it with a constructed NextRequest; do not boot the dev server. The config.matcher array is not applied by that call, so matcher coverage needs its own test that converts the patterns to URLPattern and asserts which paths match — otherwise you are testing behaviour on paths the middleware will never see. Details in how to chain multiple middlewares in Next.js App Router.

Remix. Edge-deployed loaders and actions are plain functions taking a request-bearing argument, which makes them the easiest of the three to unit test. Build a Request, pass a minimal { request, params, context }, and assert on the returned Response. Keep the platform context shape behind an interface so the test supplies a plain object.

SvelteKit. handle in src/hooks.server.ts receives { event, resolve }. Pass a fake resolve that returns a sentinel Response and records whether it was called — that single fake gives you clean assertions on both header injection and short-circuiting, since an early return means resolve was never invoked.

Debugging workflow

  1. Reproduce at the lowest tier that can hold the bug. If production returns the wrong variant header, first try to express it as a decide input. If you can, the fix is a one-line pure-function change with a permanent regression test. If you cannot, that tells you the bug lives in runtime behaviour and you move up.
  2. Escalate to the sandbox. Reproduce inside @cloudflare/vitest-pool-workers with a real binding. This is where immutable-header errors, Cache API key mismatches and Durable Object ordering issues appear.
  3. Escalate to a preview. Deploy to a preview URL and hit it with curl from more than one region if you can. Compare response headers against the local run field by field; the mismatch list is your divergence report. Reading cache status headers at the edge covers how to read what the CDN tells you.
  4. Instrument rather than guess. Add a debug header carrying the decision kind and the reason string, gated behind a secret query parameter or a header only your team sends. Structured logs — see structured logging for edge functions — turn a one-off reproduction into a queryable signal.
  5. Write the test at the tier that caught it, then push it down. If a smoke test caught it, ask whether a sandbox test could have. Every bug that migrates one tier down makes the suite faster and the feedback loop tighter.

Common pitfalls

Symptom Cause Fix
Can't modify immutable headers in production only Test ran in Node, where Headers has no guard Move header-mutation tests into workerd via @cloudflare/vitest-pool-workers
Session logic passes locally, logs users out in production Local KV is strongly consistent; production is not Use a fake KV with a propagation delay and assert the degraded path
CPU-limit errors that no test predicted Emulators never enforce CPU ceilings Measure CPU in a deployed preview under realistic payload sizes
Country-based routing untested request.cf and request.geo are stubbed locally Inject the country into the pure snapshot; smoke-test the real field once
Early return passes but downstream still runs Only the status was asserted Assert that binding doubles recorded zero calls
Cache hit locally, permanent miss in production Single local cache versus many PoPs and tiers Verify cache keys in a deployed environment, not locally
Flaky waitUntil assertions Test ends before the background promise settles Collect scheduled promises in a fake ctx and await them explicitly
Suite slow enough that nobody runs it Everything was written at the sandbox tier Extract a pure decide and push the bulk of cases down

Runtime-constraints checklist

Frequently Asked Questions

Why do my tests pass locally but fail with an immutable headers error in production?

Node implementations of Headers have no immutability guard, so mutating a borrowed header object succeeds in a plain unit test. The edge runtime freezes the headers of an incoming request and of a response returned by fetch, so the same line throws. Always construct a new Headers object from the old one, and run header tests inside a real edge sandbox where the guard is enforced.

Does local emulation reproduce eventual consistency in key-value storage?

No. Local key-value emulation is a single in-process store, so a write is immediately visible to the next read. Production propagates writes across the network over seconds, which means read-after-write code paths that look correct locally can return null in production. Model the delay explicitly with a test double that hides a written value for a configurable interval.

Can I rely on local emulation to catch CPU time limit errors?

No emulator enforces the platform CPU or wall-clock ceiling, so a handler that would be killed in production runs to completion on your machine. Measure CPU on a deployed preview with realistic payload sizes, and treat any local timing figure as a lower bound rather than a prediction.

How do I test that an early return really short-circuits the chain?

Asserting the status code only proves the response was produced, not that nothing else ran. Give every dependency a double that records the calls it received, then assert that the recorded call list is empty. That is the assertion that actually proves the request never continued down the chain.

Should I mock bindings or use the real local ones?

Use hand-written in-memory doubles for the fast tier, because they let you inject failure and delay that the real local binding cannot produce. Use the real local binding in the sandbox tier when the binding’s own guarantees are what you are testing, such as single-instance ordering in a Durable Object.

How many smoke tests against a preview deployment are enough?

A handful, typically three to eight. They exist to cover only what no emulator reproduces: geolocation fields, TLS and Secure cookie behaviour, cache tier hits, cold start timing and the CPU ceiling. Anything that can be asserted without the network belongs at a lower tier where it runs in milliseconds.