Last winter I pushed an edge function that passed every local test and still took down personalization for half our European traffic. It wasn’t a logic bug — the code assumed Node’s Buffer was available and that a fetch subrequest would always complete in under 200ms. At the edge, neither was true. If you’ve ever watched a green CI pipeline turn into a wave of 500s at the PoP, you know why testing at the edge needs its own playbook.
The Edge Runtime Isn't Node: What Your Unit Tests Are Missing
After a decade of shipping code to CDN edge nodes, the biggest mindset shift I’ve had to teach teams is this: you’re not writing for Node.js anymore. Most edge platforms run on V8 isolates or similar lightweight runtimes, not a full Node environment. There’s no filesystem, no native modules, and the globals are intentionally limited to the WinterCG spec plus a few vendor extensions. I’ve seen junior engineers waste days mocking `fs.readFile` when they should’ve been testing against `Request`, `Response`, and `fetch` directly. Your unit tests have to reflect that constrained world or they’re not testing anything useful.
That means your first layer of testing should isolate pure logic from runtime APIs. Don't try to spin up a whole edge simulator for a function that just rewrites headers or normalizes a URL. Instead, write fast, deterministic unit tests that inject the Fetch API primitives. I typically keep all my business logic in pure functions that accept a `Request` and return a new `Request` or `Response`, which makes them trivial to test without network calls. The trick is to stop mocking Node and start using the platform's own primitives. For anything touching HTTP Working Group Specifications like cache-control or header handling, validate against the spec, not just what Chrome happens to do.
When you do need to test runtime-specific behavior — like cold starts or memory pressure — remember that your test harness needs to simulate those constraints. A function that allocates 150MB will pass locally but get killed at the edge. I've learned to pair my logic tests with runtime assertion tests that check bundle size and initialization time. If you’re seeing inconsistent startup latency, you’ll want to read through Edge Function Cold Start Optimization: Reducing Initialization Latency to Milliseconds before you blame your tests. Here’s the basic pattern I use for unit testing a header-manipulation handler without any network dependency:
// handler.js - pure logic, no side effects
export function addSecurityHeaders(request) {
const response = new Response('OK', { status: 200 });
// Clone request headers to response for testing
response.headers.set('x-request-id', request.headers.get('x-request-id') || crypto.randomUUID());
response.headers.set('strict-transport-security', 'max-age=31536000');
response.headers.set('x-content-type-options', 'nosniff');
return response;
}
// handler.test.js - runs in Vitest / Jest with no extra setup
import { describe, it, expect } from 'vitest';
import { addSecurityHeaders } from './handler.js';
describe('addSecurityHeaders', () => {
it('preserves existing request ID', () => {
const req = new Request('https://example.com', {
headers: { 'x-request-id': 'test-123' }
});
const res = addSecurityHeaders(req);
expect(res.headers.get('x-request-id')).toBe('test-123');
expect(res.headers.get('strict-transport-security')).toBe('max-age=31536000');
});
it('generates ID when missing', () => {
const req = new Request('https://example.com');
const res = addSecurityHeaders(req);
expect(res.headers.get('x-request-id')).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
});
});
Integration Testing for Subrequests, KV, and Cache Interactions
Unit tests tell you your logic is sound, but they can’t tell you what happens when your function actually talks to the edge platform. This is where most teams get burned. Your function doesn’t live in isolation — it makes subrequests to origin, reads from edge KV or cache API, and competes for tight CPU and memory budgets. I once debugged a cart personalization function that worked perfectly in unit tests but failed under load because it made three sequential fetches to an origin that had a 10ms p95. At the edge, those sequential calls blew the wall-clock limit and the platform terminated the request.
For integration tests, you need a local runtime that’s close to production. Tools like Cloudflare’s workerd, Deno’s edge simulator, or Miniflare give you a real implementation of `fetch`, `caches.default`, and KV bindings without hitting the live network. Don’t mock `fetch` here — intercept it. You want to verify that your code correctly handles redirects, 103 Early Hints, and streamed responses. The web.dev Articles on streaming and early hints are a solid reference for what correct behavior looks like. And if you’re hitting platform limits, check Edge Function Execution Limits: CPU Time, Subrequests, and Creative Workarounds early — you can’t test your way around a hard 50ms CPU cap if your code needs 80ms.
I also run all integration tests with strict memory assertions. Edge isolates typically give you 64-256MB, and there’s no swap. A memory leak that’s invisible on your 32GB MacBook will cause evictions at the PoP. That’s why I now run integration suites inside containers limited to 128MB and track heap after each test. For more on staying within those boundaries, see Edge Function Memory Management: Working Within Tight Limits at CDN Scale. This setup catches real-world failures before they do:
// integration.test.js - using Miniflare / workerd
import { Miniflare } from 'miniflare';
const mf = new Miniflare({
modules: true,
scriptPath: './dist/worker.js',
kvNamespaces: ['CACHE_KV'],
cache: true,
bindings: { ORIGIN_URL: 'https://origin.example.com' }
});
describe('edge integration: stale-while-revalidate', () => {
it('serves stale cache on origin timeout and revalidates in background', async () => {
// Seed KV and Cache API
await mf.getKVNamespace('CACHE_KV').put('product:123', JSON.stringify({ price: 99 }));
// Mock origin to timeout on first call, succeed on second
const originMock = mf.createFetchMock()
.get('https://origin.example.com/product/123')
.times(1).replyWith(() => new Promise(r => setTimeout(() => r([504, 'Timeout']), 800)))
.get('https://origin.example.com/product/123')
.replyWith(JSON.stringify({ price: 109 }), { status: 200 });
const res1 = await mf.dispatchFetch('https://cdn.example.com/product/123');
expect(res1.status).toBe(200);
expect(await res1.json()).toHaveProperty('price', 99);
expect(res1.headers.get('x-cache')).toBe('STALE');
// Background revalidation should have updated KV
await new Promise(r => setTimeout(r, 200));
const updated = await mf.getKVNamespace('CACHE_KV').get('product:123', 'json');
expect(updated.price).toBe(109);
});
});
Choosing the Right Test Pyramid for the Edge
So how much of each test type do you actually need? In a traditional web app you might aim for 70% unit, 20% integration, 10% end-to-end. At the edge, I’ve flipped that a bit after getting paged too many times. Because the runtime is so constrained and the network is part of your logic, integration tests carry more weight than they do for a regular backend. I now budget for roughly 50% unit, 35% integration, and 15% end-to-end, with that last slice running against a live staging PoP.
End-to-end tests at the edge aren’t optional, but they’re expensive and flaky if you overuse them. You’re testing through real PoPs, real Anycast routing, and real cache states. I run a small, focused set of E2E checks on every deploy to staging: cold start latency, geographic routing, and cache hit/miss behavior. We don’t assert on exact response bodies as much as we assert on headers like `cf-cache-status`, `x-edge-location`, and `server-timing`. Those tell you whether your function actually ran where you thought it did. Don't run your full suite this way — you'll get rate-limited and your tests will be non-deterministic.
The table below is the distribution I’ve settled on after running edge workloads handling about 40k rps across 300+ PoPs. These numbers aren’t theoretical; they come from our CI and staging observability over the last six months. Use it as a starting point and adjust based on how many subrequests and stateful bindings your function uses.
| Test Level | Environment | Avg. Duration per Test | Primary Failure Caught |
|---|---|---|---|
| Unit | Local Vitest / Jest (no runtime) | 8 - 15 ms | Header logic, URL rewrites, auth parsing |
| Integration (workerd) | Local isolate + Miniflare/workerd | 45 - 120 ms | Subrequest timeouts, KV consistency, Cache API misuse |
| Integration (constrained) | Docker limited to 128MB / 50ms CPU | 80 - 200 ms | Memory evictions, CPU limit termination |
| End-to-End | Staging PoP (live CDN) | 400 - 900 ms | Anycast routing, cold start, tiered cache coherency |
Building a Local Harness That Actually Behaves Like the Edge
Here's where most teams trip: your local Node tests pass green, then the function blows up in production with a cryptic `ReferenceError: process is not defined` or hits a 50ms CPU wall. You can't just `npm test` your way out of this. You need a harness that enforces the same constraints as your CDN's isolate — 128MB memory, 10-50ms CPU time, no `fs`, no native addons, and WinterCG-only globals.
We've standardized on `workerd` (via Miniflare 3 / `wrangler dev`) for Cloudflare and `fastly-compute-js` local server for Fastly. Don't mock the runtime — run it. Vitest with the `pool: 'threads'` and a custom environment gives you sub-100ms test iteration while still executing inside the actual V8 isolate. The key is seeding realistic bindings: KV namespaces with 1MB value limits, cache API that respects `Cache-Control`, and a fetch mock that enforces a 5-10ms subrequest latency plus random 2% failure injection. If your tests don't account for that latency, you're not testing the edge — you're testing localhost.
For integration, we spin up a single `unstable_dev` worker per test file, not per test. Booting a workerd isolate costs ~400-600ms, so per-file reuse cuts a 200-test suite from 90 seconds to under 18 seconds. Pair that with MSW (Mock Service Worker) at the outbound fetch layer so you can assert on subrequest count, headers, and cache revalidation without hitting a real origin.
// vitest.config.ts + edge harness
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'miniflare',
environmentOptions: {
kvNamespaces: ['CACHE_KV', 'CONFIG_KV'],
bindings: { ORIGIN_URL: 'https://origin.example.com' },
modules: true,
compatibilityDate: '2024-09-23'
},
poolOptions: { threads: { singleThread: false } }
}
})
// handler.test.ts
import { describe, it, expect, vi } from 'vitest'
describe('edge auth + cache', () => {
it('serves stale-while-revalidate from cache on origin 500', async () => {
const worker = await getMiniflareWorker() // cached workerd instance
// Prime cache
await worker.fetch('https://cdn.example.com/api/user', { cf: { cacheTtl: 60 } })
// Simulate origin failure
mockOrigin.get('/api/user').reply(500, 'origin down')
const res = await worker.fetch('https://cdn.example.com/api/user')
expect(res.status).toBe(200)
expect(await res.headers.get('x-cache')).toBe('STALE')
expect(mockOrigin.history).toHaveLength(1) // no thundering herd
})
})
Advanced Patterns: Killing Flakes, Cache Ghosts, and Cold-Start Surprises
If your edge tests are flaky 5-10% of the time, it's almost never Vitest — it's nondeterminism you haven't isolated. The three culprits we see on every CDN platform: subrequest timeouts, KV eventual consistency, and poisoned cache state leaking between tests. Don't use `await fetch(origin)` without an `AbortSignal.timeout(2000)`. Production subrequests will hang for 10-15 seconds before the platform kills them, but your test should fail fast. We wrap every outbound fetch with a timeout and assert that the function returns a 503 or stale fallback within 50ms, not just that it throws.
For KV and cache, you must reset state explicitly. KV reads are eventually consistent — a write in `us-east` might take 2-5 seconds to replicate globally. In tests, don't assume `await KV.put()` is immediately readable from another isolate. Use `await kv.put(key, val, { expirationTtl: 60 })` and then force a `waitUntil` drain: `await drainAllWaitUntils(worker)`. And never share cache between tests. We've debugged too many "ghost" 200s where test B passed because test A primed `cache.match()` with a stale object. Call `await caches.default.delete(request)` in a `beforeEach`, or better, namespace your cache keys per test with `x-test-id: ${expect.getState().currentTestName}`. If you're still chasing invisible state, you'll want Edge Function Debugging Techniques: Console, Tail Workers, and Remote Inspection paired with `wrangler tail --format=json` to see what's actually hitting the isolate in live traffic.
Finally, treat E2E differently. Don't run full Puppeteer against your deployed edge on every commit — it's slow (45-90 seconds per run) and burns through request quotas. Our pyramid is 70% unit (isolate), 20% integration (workerd + mocked origin), 10% E2E (canary deploy + synthetic checks). For E2E, deploy to a preview hostname with Edge Function Versioning and Deployment Patterns: Blue-Green, Canary, and Gradual and run 20-30 critical path checks from 3+ PoPs using something like Checkly or Catchpoint. If you're doing A/B Routing with Edge Functions: Traffic Splitting Without Origin Changes, your E2E must also validate cookie stickiness and 50/50 split variance — we've seen off-by-one hashing bugs skew splits to 58/42 without triggering any unit test failures.
Frequently Asked Questions
Why do my edge function unit tests pass locally but fail with 1101 or CPU limit errors in production?
Because Node doesn't enforce isolate limits. Your local test has unlimited CPU and memory, but production gives you 50ms CPU time on Cloudflare or 100ms on Fastly Compute, and 128MB RAM. A synchronous `JSON.parse()` on a 500KB payload or a regex with catastrophic backtracking will pass in Jest and time out at the edge. Run your unit tests inside workerd/Miniflare and add a CPU assertion: if your handler consistently takes >30ms in the harness, refactor. Also check for Node-only APIs like `process.env`, `Buffer`, or `require('fs')` — polyfill or remove them.
How do I reliably test KV and Cache interactions without flaky results?
Isolate and drain. First, don't share KV or Cache state between tests — reset namespaces and call `caches.default.delete()` in `beforeEach`. Second, KV is eventually consistent, so await both the `put()` and the `waitUntil` queue. Use `executionCtx.waitUntil()` mocks and drain them before asserting. For Cache API, assert on the full header set (`Cache-Control`, `CDN-Cache-Control`, `Vary`) and test revalidation: prime cache, mock origin 500, then expect a `STALE` serve. Inject 5-10ms latency and 2% failure rate on subrequests to catch race conditions early.
Should I mock fetch() or hit a real origin in integration tests?
Mock at the network edge, not the function. Use MSW or a local origin stub that mimics real latency (15-40ms TTFB) and failure modes (500s, timeouts, truncated bodies). Hitting a real origin makes your suite slow, non-deterministic, and rate-limited — plus you can't test thundering herd or fallback logic. Keep 2-3 E2E tests that hit a real preview origin deployed via canary, but keep your 100+ integration tests fully mocked and hermetic. That keeps your suite under 20 seconds.
What's the best way to test A/B or geolocation routing at the edge?
Don't trust headers alone. In your harness, send requests with distinct `cf-ipcountry`, `cf-connecting-ip`, and `cookie: bucket=a` values and assert the `x-variant` or `set-cookie` response. Test stickiness: the same user ID should hash to the same bucket 100% of the time over 1,000 iterations. Test distribution: 10,000 random IDs should split 49.5% to 50.5% at 95% confidence. And test fallback — if your config KV is missing, does traffic default to control or 500? We track this with synthetic E2E from multiple PoPs to catch anycast routing quirks.