Skip to main content
Back to Blog
Tutorial
2026-07-10

Cloudflare Workers Testing Guide

Test Cloudflare Workers with Vitest, workerd runtime coverage, bindings, fetch handlers, scheduled events, and edge-specific mocks in CI suites.

Cloudflare Workers Testing Guide

The handler passes in Node because Request exists, then fails at the edge because the KV binding is undefined and the cache header logic depends on Workers runtime behavior. Cloudflare Workers testing has to exercise the runtime boundary, not just call a function that happens to accept a request.

Cloudflare's Vitest integration runs tests in the Workers runtime through @cloudflare/vitest-pool-workers. That matters because Workers code uses platform bindings, Web Fetch APIs, Durable Objects, D1, KV, R2, queues, and runtime-specific modules that ordinary Node tests do not faithfully emulate.

This guide covers a practical test stack: direct fetch handler tests, binding usage, environment separation, scheduled handlers, and CI tradeoffs. For broader serverless QA patterns, use serverless testing complete guide. For web-facing regression coverage around Workers-backed apps, pair it with web testing checklist 2026.

Use the Workers Vitest Pool for Runtime Behavior

A Worker can be written as a module exporting fetch. The test should call it through the Workers test environment, passing an env object that matches bindings used by the handler.

// src/index.ts
export interface Env {
  RATE_LIMIT_KV: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname !== '/health') {
      return Response.json({ error: 'not_found' }, { status: 404 });
    }

    const flag = await env.RATE_LIMIT_KV.get('maintenance');

    return Response.json(
      {
        ok: flag !== 'on',
        region: request.headers.get('cf-ipcountry') ?? 'unknown',
      },
      {
        headers: {
          'cache-control': 'no-store',
        },
      },
    );
  },
};
// test/health.test.ts
import { env } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
import worker from '../src/index';

describe('health endpoint', () => {
  it('returns no-store health status from the Worker runtime', async () => {
    await env.RATE_LIMIT_KV.put('maintenance', 'off');

    const response = await worker.fetch(
      new Request('https://example.com/health', {
        headers: { 'cf-ipcountry': 'IN' },
      }),
      env,
    );

    expect(response.status).toBe(200);
    expect(response.headers.get('cache-control')).toBe('no-store');
    await expect(response.json()).resolves.toEqual({
      ok: true,
      region: 'IN',
    });
  });
});

The test imports env from cloudflare:test, which is provided by the Workers Vitest pool. That is different from manually inventing a plain object for KV. When bindings matter, test with bindings.

Configuration That Makes Tests Honest

Your Vitest config should identify Worker tests and use the Workers pool. Keep Node unit tests separate if the repository also contains pure TypeScript utilities.

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

export default defineWorkersConfig({
  test: {
    poolOptions: {
      workers: {
        wrangler: {
          configPath: './wrangler.toml',
        },
      },
    },
  },
});

This config tells Vitest to run Worker tests in the workerd-backed pool with the Wrangler configuration. If you have ordinary Node tests for helper functions, consider a separate Vitest project or file pattern so helper tests do not pay the runtime cost.

Test layerRuntimeBest forAvoid
Pure unitNode or jsdomURL parsing, header builders, small pure functionsPretending KV/D1 bindings exist
Worker integrationWorkers Vitest poolFetch handler, bindings, runtime APIsSlow exhaustive data matrices
Local previewWrangler dev or previewManual debugging and browser checksReplacing automated assertions
Deployed smokeReal Cloudflare environmentRouting, secrets, production-like bindingsLarge destructive test suites

The goal is not to run every test in the Workers pool. The goal is to run runtime-sensitive tests there.

Bindings Need Contract Tests

Bindings are the edge equivalent of dependencies. A Worker that expects env.SESSIONS as KV and receives nothing will fail in production. Tests should verify binding names, behavior, and failure modes.

// test/session-cache.test.ts
import { env } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';
import worker from '../src/index';

describe('session cache behavior', () => {
  it('returns 401 when the session key is missing from KV', async () => {
    const response = await worker.fetch(
      new Request('https://example.com/account', {
        headers: { cookie: 'sid=missing-session' },
      }),
      env,
    );

    expect(response.status).toBe(401);
    await expect(response.json()).resolves.toMatchObject({
      error: 'session_expired',
    });
  });

  it('reads the session from KV before returning account data', async () => {
    await env.SESSIONS.put(
      'sid:valid-session',
      JSON.stringify({ userId: 'user_123', email: 'qa@example.com' }),
    );

    const response = await worker.fetch(
      new Request('https://example.com/account', {
        headers: { cookie: 'sid=valid-session' },
      }),
      env,
    );

    expect(response.status).toBe(200);
    await expect(response.json()).resolves.toMatchObject({
      userId: 'user_123',
    });
  });
});

This test assumes SESSIONS is configured in Wrangler for the test environment. If the binding name changes, the test should fail before deployment.

Edge-Specific Bugs Worth Testing

Workers use Web-standard APIs, but the edge environment has its own failure patterns. Tests should reflect those patterns rather than only checking happy-path JSON.

RiskWorker-specific angleTest idea
Missing bindingRuntime env differs from local mockCall handler with configured env and assert binding behavior
Cache headersCDN behavior depends on exact headersAssert cache-control, vary, and status together
Request body reuseStreams can be consumed onceTest middleware plus handler reading body
Geo headersCountry or colo data may be absent locallyProvide headers explicitly and test fallback
Durable Object id routingObject identity controls stateTest same id vs different id behavior
D1 migrationsSchema mismatch appears at runtimeRun query against test D1 binding
Queue payload shapeProducers and consumers driftValidate message body before enqueue

Good Worker tests are often contract tests around the platform boundary: request in, env binding call, response out.

Testing Scheduled Handlers

Workers can export scheduled handlers for Cron Triggers. Keep the business work in a function that accepts env, but still test the scheduled export so wiring does not rot.

// src/cron.ts
export async function rebuildSearchIndex(env: Env): Promise<void> {
  const skills = await env.DB.prepare('select id, title from skills').all();
  await env.SEARCH_QUEUE.send({ type: 'reindex', count: skills.results.length });
}

// src/index.ts
import { rebuildSearchIndex } from './cron';

export default {
  async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
    await rebuildSearchIndex(env);
  },
};
// test/scheduled.test.ts
import { env } from 'cloudflare:test';
import { describe, expect, it, vi } from 'vitest';
import worker from '../src/index';

describe('scheduled worker', () => {
  it('runs the index rebuild job from the scheduled export', async () => {
    const event = {
      scheduledTime: Date.now(),
      cron: '0 * * * *',
    } as ScheduledEvent;

    const waitUntil = vi.fn();
    const context = { waitUntil } as unknown as ExecutionContext;

    await worker.scheduled(event, env, context);

    expect(waitUntil).not.toHaveBeenCalled();
  });
});

If your scheduled handler uses context.waitUntil, assert that it is called with a promise and test the underlying function separately. The key is to cover both the exported Worker shape and the domain function.

Mocks Should Stay at Your Boundary, Not Cloudflare's

Mock your own API clients, not the Workers runtime. If a handler calls a third-party HTTP API, inject a small client or use fetch stubbing carefully. But do not replace Request, Response, KV, or D1 with casual hand-written objects unless the test is a pure unit test.

Mock targetGood idea?Reason
Payment API clientYesExternal service behavior is outside Worker contract
Email sender wrapperYesKeeps tests deterministic
KVNamespace by plain objectUsually no for integrationBinding semantics and async API can differ
Request/Response classesNo in Worker testsRuntime provides real Web APIs
Date/timeSometimesUse Vitest timers for expiry logic
Environment secretsYes with test valuesAvoid production secrets in tests

This distinction keeps tests from becoming a fantasy runtime.

CI Strategy for Workers

Worker tests often need Wrangler config and bindings. Keep CI explicit: install dependencies, run type checks if allowed in your project, run Worker Vitest tests, and run a small deployed smoke check after deployment. Secrets should come from CI secret storage or Cloudflare environment configuration, not committed files.

For pull requests, prefer local test bindings and non-destructive data. For production smoke, hit safe endpoints like /health, version metadata, or a read-only synthetic account. Do not run destructive writes against production just because Workers make deployment easy.

Testing Middleware-Style Request Rewrites

Workers often sit in front of an origin and rewrite requests, attach headers, or short-circuit responses. Those tests should assert both the outgoing fetch shape and the client response. Keep the origin call behind an injectable function if the Worker code is complex, but verify the fetch handler path that performs the rewrite.

// src/proxy.ts
export interface ProxyEnv {
  ORIGIN_URL: string;
}

export async function proxyToOrigin(request: Request, env: ProxyEnv): Promise<Response> {
  const incoming = new URL(request.url);
  const target = new URL(incoming.pathname, env.ORIGIN_URL);
  target.search = incoming.search;

  const originRequest = new Request(target, request);
  originRequest.headers.set('x-edge-source', 'cloudflare-worker');

  return fetch(originRequest);
}
// test/proxy.test.ts
import { describe, expect, it, vi } from 'vitest';
import { proxyToOrigin } from '../src/proxy';

describe('origin proxy rewrite', () => {
  it('preserves path and query while adding the edge source header', async () => {
    const fetchMock = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(Response.json({ ok: true }));

    const response = await proxyToOrigin(
      new Request('https://edge.example.com/api/search?q=worker'),
      { ORIGIN_URL: 'https://origin.example.com' },
    );

    expect(response.status).toBe(200);

    const originRequest = fetchMock.mock.calls[0][0] as Request;
    expect(originRequest.url).toBe('https://origin.example.com/api/search?q=worker');
    expect(originRequest.headers.get('x-edge-source')).toBe('cloudflare-worker');

    fetchMock.mockRestore();
  });
});

This is a pure unit-style test because it focuses on rewrite logic, not bindings. Keep this layer fast. Then add one Worker-runtime integration test that proves the exported handler actually calls the proxy path for the relevant route.

Durable Objects and Stateful Edge Behavior

Durable Objects change the testing question from "what response did one fetch return" to "what state is preserved for this object id." Tests should check same-id continuity and different-id isolation. Even if the detailed object implementation has unit tests, keep an integration test around the Worker binding and stub path.

Durable Object behaviorTest assertion
Same object id receives repeated requestsCounter, session, or lock state persists
Different object ids are isolatedState written through one id is absent from another
Object validates request shapeBad request returns controlled status
Object handles concurrent requestsLocking or ordering behavior is explicit
Worker routes to expected object idURL or tenant maps to stable id

Stateful edge bugs are expensive because they may appear only under concurrency or specific routing keys. Add tests for the id mapping function even if full concurrency coverage lives elsewhere.

D1 and Migration-Aware Tests

When a Worker uses D1, the test environment needs schema setup. Treat migrations as part of the test input. A handler test that only mocks a returned row will not catch a renamed column, missing index, or migration order problem.

// test/users-d1.test.ts
import { env } from 'cloudflare:test';
import { describe, expect, it } from 'vitest';

describe('D1 user lookup', () => {
  it('reads a user row using the migrated schema', async () => {
    await env.DB.prepare(
      'insert into users (id, email, status) values (?, ?, ?)',
    )
      .bind('user_1', 'qa@example.com', 'active')
      .run();

    const row = await env.DB.prepare('select email, status from users where id = ?')
      .bind('user_1')
      .first();

    expect(row).toEqual({
      email: 'qa@example.com',
      status: 'active',
    });
  });
});

This is not a substitute for application tests. It is a cheap warning that the binding and schema expected by the Worker are aligned in the test runtime.

Queue Producers and Consumers

Workers often enqueue messages for asynchronous processing. Test the producer contract separately from the consumer contract. The producer should send a payload with a stable type, version, and idempotency key. The consumer should reject or park malformed messages instead of throwing an unhelpful runtime error.

Queue contractProducer testConsumer test
Message typeEnqueued body includes known typeUnknown type is rejected safely
VersionBody includes schema versionUnsupported version is handled
IdempotencyStable key includedDuplicate message does not double-apply
TraceabilityCorrelation id includedLogs or audit use same id
Retry behaviorTransient failure is surfacedPoison message path is controlled

Do not rely on a broad end-to-end queue test alone. It can pass while the payload has become hard for future consumers to evolve. A small schema assertion at the producer boundary is cheap insurance.

Secrets and Environment Separation

Workers make it easy to bind secrets and variables per environment. Tests should verify behavior with test values, but they should never require production secrets. Use non-production bindings and explicit environment names. If a feature is disabled in preview because a secret is absent, assert the controlled disabled response rather than letting the Worker crash.

Also test that diagnostic endpoints do not echo secrets. Edge logs and JSON error payloads are easy places to leak environment details. A negative test for known test-secret values in error responses can catch accidental dumps before deployment.

Environment mistakeTest to add
Missing secret crashes handlerAssert 503 or feature-disabled response
Preview uses production bindingCheck environment metadata endpoint or deployment config
Error payload exposes envSearch response body for known test secret
Local test depends on prod dataSeed test binding with synthetic records

The goal is deterministic behavior when configuration is wrong. A clear failure response is easier to operate than an edge exception with no context.

Testing Cache API Decisions

Workers often make cache decisions close to the user. If the Worker sets cache keys, varies by header, or bypasses cache for authenticated traffic, write tests that assert those decisions directly. A response that is functionally correct but cached under the wrong key can leak data or serve stale content globally.

Cache decisionAssertion
Authenticated requestResponse has cache-control: no-store
Public assetLong-lived cache header is present
Locale variationvary includes language header
Preview modeCache bypass flag is honored
Error response500 response is not cached

Cache tests should use synthetic requests with the exact headers the edge receives. Treat cache behavior as part of the API contract, not a deployment detail.

Review cache tests whenever routing or authentication changes. A harmless-looking header change can alter which users share a cached object. For Workers that front patient, account, or billing data, conservative no-store assertions are often the safer default until public caching is deliberately designed. Document each public cache exception in code review, with owner approval and expiry for future audits across environments consistently.

Frequently Asked Questions

Should I test Cloudflare Workers in Node or the Workers runtime?

Use Node for pure helper functions. Use the Workers Vitest pool for fetch handlers, bindings, runtime APIs, and anything that depends on workerd behavior. Runtime-sensitive tests in Node can pass while production fails.

What should I assert in a Worker fetch test?

Assert status, headers, body shape, and binding-visible side effects. Workers often break through headers, cache behavior, or missing env bindings, not only through JSON payload mistakes.

Can I mock KV with a JavaScript Map?

For a pure unit test around your own wrapper, yes. For Worker integration tests, prefer the configured test binding from cloudflare:test so the handler uses the same async binding shape it uses at runtime.

How do I test Cron Trigger code?

Export a scheduled handler, keep domain work in a separate function, and test both. Call the scheduled export with a ScheduledEvent-shaped object and test the domain function against test bindings.

Do I still need deployed smoke tests?

Yes. Local Worker tests catch code and binding behavior, but deployed smoke tests catch route configuration, environment secrets, production bindings, and edge deployment mistakes.

Cloudflare Workers Testing Guide | QASkills.sh