Unit Testing Edge Middleware with Vitest
This guide is part of Edge Runtime Fundamentals & Platform Constraints. It walks through a working Vitest setup for edge middleware: how to configure the environment, build Request fixtures, assert on status, headers and redirects, fake env bindings and ctx.waitUntil, and when to escalate to real workerd execution.
The problem
You write your first middleware test and it does not run. ReferenceError: Request is not defined, or Cannot find module 'cloudflare:test', or the import of middleware.ts drags in a framework module that expects a bundler. You fix the environment, the test finally executes, and now it passes — but it passes for the wrong reason. You asserted response.status === 302 and got a green tick, while the redirect target was wrong and the Set-Cookie you meant to attach never appeared. Three weeks later, the same middleware throws TypeError: Can't modify immutable headers in production on a line your test executed hundreds of times.
Each of these is a different symptom of one underlying issue: the test process is not the runtime, and the default assertion is not the interesting one.
Root cause: the test host is not the edge isolate
Vitest runs on Node by default. Node 18 and later provide fetch, Request, Response and Headers through undici, which is why a naive test can look like it works. But undici is a different implementation of the same spec, and it differs in exactly the places edge middleware is fragile.
The most consequential difference is the header guard. The Fetch spec defines guards that make the headers of an incoming Request and of a Response returned by fetch() immutable. Workers and Deno enforce them. undici inside Vitest largely does not, so request.headers.set('x-user', id) succeeds locally and throws at the edge. No amount of care in writing the test catches this, because the test host simply lacks the constraint.
The second is that env and ctx do not exist. In production your handler receives platform-provided bindings and a context object with waitUntil and passThroughOnException. Under plain Vitest there is nothing to pass, so you either supply substitutes deliberately or you write tests that never exercise those paths.
The third is that a config.matcher array is inert in a unit test. Calling the exported middleware function bypasses the matcher entirely, so a test can assert behaviour on /_next/static/chunk.js that the middleware will never actually see — and, worse, can pass while the real matcher is excluding a path you needed covered.
The strategy that resolves all three: run the bulk of your cases on Node for speed, keep them honest by never mutating borrowed objects, run the runtime-sensitive subset inside real workerd, and test the matcher as its own separate artefact.
Step 1 — Install and configure Vitest for an edge-like environment
Start with the fast tier. edge-runtime provides a Node-hosted environment that removes Node globals and installs the Web API surface, which catches accidental process.env or Buffer usage at test time rather than at deploy time.
npm i -D vitest @edge-runtime/vm @cloudflare/vitest-pool-workers
A single config file can host both tiers using Vitest projects: one fast project on the edge-runtime VM, one high-fidelity project inside workerd.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { defineWorkersProject } from '@cloudflare/vitest-pool-workers/config';
export default defineConfig({
test: {
// Fast tier: pure logic, runs on every save.
environment: 'edge-runtime',
globals: true,
include: ['test/unit/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
clearMocks: true,
restoreMocks: true,
testTimeout: 5_000,
coverage: {
provider: 'v8',
include: ['lib/**/*.ts', 'middleware.ts'],
thresholds: { statements: 85, branches: 80, functions: 85, lines: 85 },
},
projects: [
// Project A: the fast tier, same settings as above.
{
test: {
name: 'unit',
environment: 'edge-runtime',
include: ['test/unit/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
},
// Project B: real workerd, real bindings, real header guards.
defineWorkersProject({
test: {
name: 'workerd',
include: ['test/integration/**/*.test.ts'],
poolOptions: {
workers: {
singleWorker: true,
miniflare: {
compatibilityDate: '2026-07-01',
compatibilityFlags: ['nodejs_compat_v2'],
kvNamespaces: ['SESSIONS'],
durableObjects: { LIMITER: 'RateLimiter' },
bindings: { ENVIRONMENT: 'test' },
},
},
},
},
}),
],
},
});
The edge-runtime environment is the important choice for the fast project. Under it, process, Buffer and require are absent, so a dependency that quietly imports a Node built-in fails your test run instead of your deploy — the same class of failure discussed in polyfill strategies for Node.js APIs at the edge.
Step 2 — Build Request fixtures you can read at a glance
Hand-constructing a Request in every test buries the interesting variable under boilerplate. One builder with sensible defaults makes each test a single line of intent.
// test/fixtures.ts
export interface FixtureOptions {
path?: string;
method?: string;
cookies?: Record<string, string>;
bearer?: string;
headers?: Record<string, string>;
country?: string;
body?: string;
}
export function req(opts: FixtureOptions = {}): Request {
const url = new URL(opts.path ?? '/', 'https://app.example.com');
const headers = new Headers(opts.headers ?? {});
if (opts.cookies && Object.keys(opts.cookies).length > 0) {
headers.set(
'cookie',
Object.entries(opts.cookies)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('; '),
);
}
if (opts.bearer) headers.set('authorization', `Bearer ${opts.bearer}`);
if (opts.country) headers.set('x-vercel-ip-country', opts.country);
return new Request(url, {
method: opts.method ?? 'GET',
headers,
body: opts.body,
});
}
Always use an absolute https:// URL. A relative one throws when new URL() parses it, and an http:// one hides Secure-cookie behaviour that only appears over TLS. Building the fixture from a real origin also means url.origin comparisons in your middleware behave the way they will in production.
Step 3 — Fake the env bindings and ctx
The handler signature at the edge is (request, env, ctx). Supply all three explicitly rather than letting a test accidentally rely on module-scope state.
// test/harness.ts
export function recordingKV() {
const store = new Map<string, string>();
const calls: Array<{ op: 'get' | 'put' | 'delete'; key: string }> = [];
return {
calls,
seed(key: string, value: string) { store.set(key, value); },
async get(key: string) { calls.push({ op: 'get', key }); return store.get(key) ?? null; },
async put(key: string, value: string) { calls.push({ op: 'put', key }); store.set(key, value); },
async delete(key: string) { calls.push({ op: 'delete', key }); store.delete(key); },
};
}
export function fakeCtx() {
const scheduled: Promise<unknown>[] = [];
let passedThrough = false;
return {
scheduled,
waitUntil(p: Promise<unknown>) { scheduled.push(p); },
passThroughOnException() { passedThrough = true; },
get didPassThrough() { return passedThrough; },
/** Await every background promise so assertions are never racy. */
async settle() { await Promise.allSettled(scheduled); },
};
}
export function fakeEnv(overrides: Record<string, unknown> = {}) {
return { SESSIONS: recordingKV(), ENVIRONMENT: 'test', ...overrides };
}
settle() is what removes flakiness. ctx.waitUntil is fire-and-forget by design, so without an explicit await your assertion races the background promise and fails perhaps one run in twenty. Collect the promises, await them, then assert.
Step 4 — Assert on status, headers, redirects and early returns
The default assertion — status equals a number — is the weakest one available. Assert the whole observable surface, and assert absence as deliberately as presence.
// test/unit/middleware.test.ts
import { describe, expect, it } from 'vitest';
import handler from '../../middleware';
import { req } from '../fixtures';
import { fakeCtx, fakeEnv } from '../harness';
describe('middleware', () => {
it('rejects an unauthenticated admin request without touching storage', async () => {
const env = fakeEnv();
const ctx = fakeCtx();
const res = await handler(req({ path: '/admin/users' }), env, ctx);
expect(res.status).toBe(401);
expect(res.headers.get('cache-control')).toBe('no-store');
expect(res.headers.get('www-authenticate')).toBe('Bearer');
// The early return must be terminal: no binding was consulted.
expect(env.SESSIONS.calls).toHaveLength(0);
expect(ctx.scheduled).toHaveLength(0);
});
it('redirects a non-US visitor to the localised root with 307', async () => {
const res = await handler(req({ path: '/', country: 'DE' }), fakeEnv(), fakeCtx());
expect(res.status).toBe(307);
expect(res.headers.get('location')).toBe('/de/');
// A 307 must not carry a body, or intermediaries may cache it oddly.
expect(await res.text()).toBe('');
});
it('injects identity headers without mutating the incoming request', async () => {
const incoming = req({ path: '/app', bearer: 'valid-token' });
const forwarded = await handler(incoming, fakeEnv(), fakeCtx());
expect(forwarded).toBeInstanceOf(Request);
const out = forwarded as Request;
expect(out.headers.get('x-user-id')).toBe('u_42');
// Spoofed inbound values must be stripped, not merged.
expect(out.headers.get('x-user-roles')).toBe('reader');
expect(incoming.headers.get('x-user-id')).toBeNull();
});
it('schedules the audit write in the background, not on the hot path', async () => {
const env = fakeEnv();
const ctx = fakeCtx();
await handler(req({ path: '/app', bearer: 'valid-token' }), env, ctx);
expect(ctx.scheduled).toHaveLength(1);
expect(env.SESSIONS.calls.filter((c) => c.op === 'put')).toHaveLength(0);
await ctx.settle();
expect(env.SESSIONS.calls.filter((c) => c.op === 'put')).toHaveLength(1);
});
});
Three of these assertions are the ones that matter and are usually missing. expect(env.SESSIONS.calls).toHaveLength(0) proves the early return short-circuited rather than merely produced a status. expect(incoming.headers.get('x-user-id')).toBeNull() proves you copied rather than mutated, which is the local proxy for the production immutable-header guard. And the split assertion around ctx.settle() proves the audit write is genuinely deferred rather than awaited inline — the difference between a 2 ms response and a 60 ms one.
Step 5 — Escalate to real workerd with vitest-pool-workers
@cloudflare/vitest-pool-workers runs your test file inside workerd. Bindings are real, header guards are enforced, the Cache API works, and Durable Objects have their genuine single-instance semantics.
// test/integration/headers.test.ts
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
import worker from '../../src/worker';
describe('runtime guards', () => {
it('throws when middleware mutates borrowed request headers', async () => {
const request = new Request('https://app.example.com/app', {
headers: { authorization: 'Bearer valid-token' },
});
// Prove the guard exists in this environment; a Node-hosted run would not throw.
expect(() => request.headers.set('x-user-id', 'u_1')).toThrow();
});
it('persists a session to the real KV binding', async () => {
const ctx = createExecutionContext();
const res = await worker.fetch(
new Request('https://app.example.com/login', { method: 'POST' }),
env,
ctx,
);
await waitOnExecutionContext(ctx); // drains waitUntil for real
expect(res.status).toBe(204);
const cookie = res.headers.get('set-cookie') ?? '';
const sid = /__Host-session=([^;]+)/.exec(cookie)?.[1];
expect(sid).toBeTruthy();
expect(await env.SESSIONS.get(`sess:${sid}`)).not.toBeNull();
});
});
waitOnExecutionContext is the workerd equivalent of ctx.settle() — it drains background work the runtime is tracking, so a KV write scheduled through waitUntil is guaranteed visible before you assert. Note that KV in this environment is still strongly consistent; the eventual-consistency behaviour of production is not reproduced, which is why the topic-level guidance in testing and local emulation for edge runtimes pushes that case to a fake with a configurable delay.
Testing the matcher as its own artefact
The exported config.matcher never runs in a unit test, so give it a dedicated test that converts each pattern and asserts inclusion and exclusion explicitly.
// middleware.ts
export const config = {
runtime: 'edge',
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/public|.*\\..*).*)'],
};
// test/unit/matcher.test.ts
import { describe, expect, it } from 'vitest';
import { config } from '../../middleware';
const matches = (path: string) =>
config.matcher.some((p) => new RegExp(`^${p}$`).test(path));
describe('matcher', () => {
it.each(['/', '/app', '/admin/users', '/checkout/step-2'])('includes %s', (p) => {
expect(matches(p)).toBe(true);
});
it.each(['/_next/static/chunk.js', '/favicon.ico', '/api/public/status', '/logo.svg'])(
'excludes %s',
(p) => expect(matches(p)).toBe(false),
);
});
This is the test that catches the most expensive category of matcher mistake: a pattern that accidentally excludes a route you needed protected. Because matcher is data, a table test over it is cheap and permanent. URLPattern based routing gets the same treatment; see using URLPattern for edge route matching.
Local versus production divergence
| Behaviour | Vitest on edge-runtime |
Vitest on workerd pool | Edge production |
|---|---|---|---|
request.headers mutability |
Mutable — no guard | Immutable, throws | Immutable, throws |
env bindings |
Whatever your fake supplies | Real local Miniflare bindings | Real distributed bindings |
ctx.waitUntil |
Your fake array; drain with settle() |
Tracked; drain with waitOnExecutionContext |
Kept alive by the platform after response |
| KV consistency | Whatever the double models | Strongly consistent | Eventually consistent, seconds |
config.matcher |
Not applied — must be tested separately | Not applied | Applied by the platform router |
| Geolocation fields | Only what the fixture sets | Placeholder request.cf values |
Real values from the PoP |
| CPU ceiling | None | None | Enforced, invocation killed on breach |
| Cold starts | None; module scope evaluated once | None | Frequent; module scope re-runs |
The first row is the reason the two-project setup earns its keep. The last two rows are the reason no Vitest configuration, however faithful, replaces a smoke test against a deployed preview.
Validating with Vitest
Test doubles are code, and a wrong double produces confidently wrong results everywhere it is used. Give the harness itself a small suite so a silent regression in fakeCtx or recordingKV cannot quietly disable assertions across the whole file.
// test/unit/harness.test.ts
import { describe, expect, it } from 'vitest';
import { fakeCtx, recordingKV } from '../harness';
import { req } from '../fixtures';
describe('harness', () => {
it('collects waitUntil promises and settles them on demand', async () => {
const ctx = fakeCtx();
let done = false;
ctx.waitUntil(Promise.resolve().then(() => { done = true; }));
expect(ctx.scheduled).toHaveLength(1);
expect(done).toBe(false);
await ctx.settle();
expect(done).toBe(true);
});
it('settles even when a background promise rejects', async () => {
const ctx = fakeCtx();
ctx.waitUntil(Promise.reject(new Error('audit sink down')));
await expect(ctx.settle()).resolves.toBeUndefined();
});
it('records every KV operation in order', async () => {
const kv = recordingKV();
kv.seed('sess:a', '{"u":1}');
await kv.get('sess:a');
await kv.put('sess:b', '{}');
await kv.delete('sess:a');
expect(kv.calls).toEqual([
{ op: 'get', key: 'sess:a' },
{ op: 'put', key: 'sess:b' },
{ op: 'delete', key: 'sess:a' },
]);
});
it('builds fixtures with an absolute https origin and encoded cookies', () => {
const r = req({ path: '/app?next=/x', cookies: { ab: 'variant b' } });
expect(new URL(r.url).origin).toBe('https://app.example.com');
expect(r.headers.get('cookie')).toBe('ab=variant%20b');
});
});
The rejection test is the one worth keeping forever. ctx.settle() uses Promise.allSettled precisely so a failing background task surfaces as a failed assertion on the recorded effect rather than as an unhandled rejection that crashes the run and hides the real failure.
Common pitfalls and resolutions
ReferenceError: Request is not defined — the test project is on the default Node environment without the Web globals. Set environment: 'edge-runtime', or move the file into the workerd project.
A test asserts status === 302 and passes with the wrong destination — the status was asserted, the location header was not. Assert every header the branch is responsible for, including the ones that should be absent.
Flaky assertions on background work — the test finished before the waitUntil promise resolved. Await ctx.settle() in the fake tier and waitOnExecutionContext(ctx) in the workerd tier.
Green tests, Can't modify immutable headers in production — header mutation was only exercised on Node. Move that case into the workerd project, and assert in the fast tier that the incoming request was left untouched.
Coverage looks high but bugs still ship — coverage counts executed lines, not asserted behaviour. A test that calls the handler and asserts nothing about effects will still light up the report.
Cannot find module 'cloudflare:test' — that specifier only resolves inside the workers pool. It cannot be imported from a file in the fast project.
Tests pass but the middleware never runs in production — the matcher excludes the route. The matcher is inert under a direct function call, so it needs its own table test.
Import of middleware.ts pulls in framework internals — keep the exported handler thin and put logic in plain modules under lib/, which import cleanly with no framework context.
Production deployment checklist
Frequently Asked Questions
Which Vitest environment should I use for edge middleware?
Use the edge-runtime environment for the fast tier, because it removes Node globals such as process and Buffer and installs the Web API surface, so an accidental Node dependency fails in the test run instead of at deploy time. Use the Cloudflare workers pool for the smaller set of tests that depend on real bindings or on runtime guards.
Why do my waitUntil assertions fail intermittently?
Because waitUntil is fire-and-forget, the handler returns before the background promise resolves, so an immediate assertion races it. Collect the promises in your fake context and await a settle helper, or call waitOnExecutionContext in the workers pool, before asserting on anything the background task produced.
Does the matcher config run when I call the middleware directly in a test?
No. Calling the exported function bypasses the platform router entirely, so the matcher has no effect and a test can assert behaviour on a path the middleware will never receive. Give the matcher array its own table test that asserts both the paths it includes and the paths it excludes.
How do I prove an early return actually short-circuited the chain?
Assert on recorded effects rather than only on the status. Give every binding a double that appends each call to an array, then assert that the array is empty and that no background promise was scheduled. A correct status with a non-empty call list means work ran that should not have.
Do I need the Cloudflare workers pool if I already test on the edge-runtime VM?
Yes for a specific subset. The edge-runtime VM does not enforce the immutable header guard, has no real bindings, and has no Cache API or Durable Object semantics. Those cases need real workerd execution; everything else runs faster and just as reliably on the lighter environment.
Can Vitest catch CPU time limit problems?
No. Neither test environment enforces a CPU or wall-clock ceiling, so a handler that production would kill runs to completion in your suite. Treat local timings as a lower bound and measure the real budget against a deployed preview with realistic payload sizes.