Cloudflare Workers Testing: Miniflare and Wrangler Test Strategies

Cloudflare Workers Testing: Miniflare and Wrangler Test Strategies

Cloudflare Workers run in the V8 isolate runtime, not Node.js. Testing them requires either Miniflare (a local Workers simulator) or Wrangler's built-in test integration with Vitest. Use Vitest + Wrangler for the most accurate tests—they run in the actual Workers runtime, not a simulation. Test KV, D1, R2, and Durable Objects using in-process bindings that Wrangler configures automatically.

Key Takeaways

Wrangler + Vitest runs tests in the actual Workers runtime. Unlike Miniflare-only tests (which simulate the runtime), wrangler vitest runs your tests inside the same V8 isolate that production uses—catching runtime-specific bugs.

@cloudflare/vitest-pool-workers is the standard test runner. Import it in vitest.config.ts, configure your wrangler.toml bindings, and tests get real KV, D1, and R2 instances in-process.

SELF.fetch() sends requests to your own worker in tests. This is how you write integration-style tests for HTTP handlers—no network required.

Test bindings directly without mocking. KV, D1, R2, and Durable Objects are injected into the test environment. You don't need to mock them; you call them directly.

env is injected into every test automatically. The bindings defined in wrangler.toml are available as env in your tests, just like they are in production handlers.

The Workers Testing Landscape

Cloudflare Workers don't run on Node.js. They run in the V8 isolate runtime (workerd), which has:

  • No Node.js APIs (require, fs, process, etc.)
  • Cloudflare-specific globals (Response, Request, caches, etc.)
  • Platform bindings (KV, R2, D1, Durable Objects)

This means standard Jest tests won't work for code that uses Workers-specific APIs. Your options:

  1. Pure unit tests — test business logic that doesn't use Workers APIs (no testing framework required, just Vitest with jsdom)
  2. Vitest + @cloudflare/vitest-pool-workers — run tests inside the actual Workers runtime
  3. Miniflare standalone — simulate the Workers runtime (less accurate but doesn't require Wrangler)

This guide focuses on the recommended approach: Vitest + @cloudflare/vitest-pool-workers.

Setup

Install dependencies:

npm install --save-dev vitest @cloudflare/vitest-pool-workers wrangler

Create vitest.config.ts:

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

export default defineWorkersConfig({
  test: {
    poolOptions: {
      workers: {
        wrangler: { configPath: "./wrangler.toml" },
        miniflare: {
          // Optional: override bindings for tests
          kvNamespaces: ["ORDERS_KV"],
          d1Databases: ["ORDERS_DB"],
          r2Buckets: ["ORDERS_BUCKET"],
        },
      },
    },
  },
});

Configure bindings in wrangler.toml:

name = "order-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"

[[kv_namespaces]]
binding = "ORDERS_KV"
id = "your-kv-namespace-id"
preview_id = "your-kv-preview-id"

[[d1_databases]]
binding = "ORDERS_DB"
database_name = "orders"
database_id = "your-database-id"

[[r2_buckets]]
binding = "ORDERS_BUCKET"
bucket_name = "orders-bucket"

Worker Code Structure

Write your worker to be testable:

// src/index.ts
export interface Env {
  ORDERS_KV: KVNamespace;
  ORDERS_DB: D1Database;
  ORDERS_BUCKET: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    
    if (url.pathname === "/orders" && request.method === "POST") {
      return handleCreateOrder(request, env);
    }
    
    if (url.pathname.startsWith("/orders/") && request.method === "GET") {
      const orderId = url.pathname.split("/")[2];
      return handleGetOrder(orderId, env);
    }
    
    return new Response("Not Found", { status: 404 });
  },
};

async function handleCreateOrder(request: Request, env: Env): Promise<Response> {
  const body = await request.json<{ customerId: string; items: any[] }>();
  
  if (!body.customerId) {
    return new Response(JSON.stringify({ error: "customerId is required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }
  
  const orderId = crypto.randomUUID();
  const order = {
    orderId,
    customerId: body.customerId,
    items: body.items,
    total: body.items.reduce((sum: number, item: any) => sum + item.price * item.quantity, 0),
    status: "pending",
    createdAt: new Date().toISOString(),
  };
  
  await env.ORDERS_KV.put(orderId, JSON.stringify(order));
  
  return new Response(JSON.stringify(order), {
    status: 201,
    headers: { "Content-Type": "application/json" },
  });
}

async function handleGetOrder(orderId: string, env: Env): Promise<Response> {
  const orderJson = await env.ORDERS_KV.get(orderId);
  
  if (!orderJson) {
    return new Response(JSON.stringify({ error: "Order not found" }), {
      status: 404,
      headers: { "Content-Type": "application/json" },
    });
  }
  
  return new Response(orderJson, {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

Tests Using SELF.fetch()

With @cloudflare/vitest-pool-workers, use SELF.fetch() to send requests to your own worker:

// src/index.test.ts
import { SELF } from "cloudflare:test";
import { describe, it, expect, beforeEach } from "vitest";

describe("Order API", () => {
  describe("POST /orders", () => {
    it("creates an order and returns 201", async () => {
      const response = await SELF.fetch("http://example.com/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customerId: "cust-123",
          items: [
            { productId: "p1", quantity: 2, price: 50.0 },
            { productId: "p2", quantity: 1, price: 30.0 },
          ],
        }),
      });
      
      expect(response.status).toBe(201);
      
      const body = await response.json<any>();
      expect(body.customerId).toBe("cust-123");
      expect(body.total).toBe(130.0);
      expect(body.status).toBe("pending");
      expect(body.orderId).toBeDefined();
    });
    
    it("returns 400 when customerId is missing", async () => {
      const response = await SELF.fetch("http://example.com/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: [{ productId: "p1", quantity: 1, price: 10 }] }),
      });
      
      expect(response.status).toBe(400);
      const body = await response.json<any>();
      expect(body.error).toContain("customerId");
    });
    
    it("returns 404 for unknown route", async () => {
      const response = await SELF.fetch("http://example.com/unknown");
      expect(response.status).toBe(404);
    });
  });
  
  describe("GET /orders/:orderId", () => {
    it("returns 404 for nonexistent order", async () => {
      const response = await SELF.fetch("http://example.com/orders/nonexistent-id");
      expect(response.status).toBe(404);
    });
    
    it("returns the order after creation", async () => {
      // Create an order first
      const createResponse = await SELF.fetch("http://example.com/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customerId: "cust-123",
          items: [{ productId: "p1", quantity: 1, price: 100 }],
        }),
      });
      const created = await createResponse.json<any>();
      
      // Retrieve it
      const getResponse = await SELF.fetch(`http://example.com/orders/${created.orderId}`);
      
      expect(getResponse.status).toBe(200);
      const retrieved = await getResponse.json<any>();
      expect(retrieved.orderId).toBe(created.orderId);
      expect(retrieved.customerId).toBe("cust-123");
    });
  });
});

Testing KV Directly

Access KV namespace directly in tests using the env fixture:

// src/kv.test.ts
import { env } from "cloudflare:test";
import { describe, it, expect, beforeEach } from "vitest";

describe("KV Operations", () => {
  it("can write and read from KV", async () => {
    await env.ORDERS_KV.put("test-key", JSON.stringify({ value: "test" }));
    
    const result = await env.ORDERS_KV.get("test-key");
    expect(JSON.parse(result!)).toEqual({ value: "test" });
  });
  
  it("returns null for missing keys", async () => {
    const result = await env.ORDERS_KV.get("nonexistent-key");
    expect(result).toBeNull();
  });
  
  it("can list keys with a prefix", async () => {
    await env.ORDERS_KV.put("orders/ord-1", "data1");
    await env.ORDERS_KV.put("orders/ord-2", "data2");
    await env.ORDERS_KV.put("sessions/sess-1", "session");
    
    const { keys } = await env.ORDERS_KV.list({ prefix: "orders/" });
    
    expect(keys).toHaveLength(2);
    expect(keys.map(k => k.name)).toContain("orders/ord-1");
    expect(keys.map(k => k.name)).toContain("orders/ord-2");
  });
});

Testing D1 (SQLite)

// src/d1.test.ts
import { env } from "cloudflare:test";
import { describe, it, expect, beforeAll } from "vitest";

describe("D1 Database", () => {
  beforeAll(async () => {
    // Create schema for tests
    await env.ORDERS_DB.exec(`
      CREATE TABLE IF NOT EXISTS orders (
        order_id TEXT PRIMARY KEY,
        customer_id TEXT NOT NULL,
        total REAL NOT NULL,
        status TEXT NOT NULL DEFAULT 'pending',
        created_at TEXT NOT NULL
      )
    `);
  });
  
  it("inserts and retrieves an order", async () => {
    await env.ORDERS_DB.prepare(
      "INSERT INTO orders (order_id, customer_id, total, status, created_at) VALUES (?, ?, ?, ?, ?)"
    )
      .bind("ord-123", "cust-456", 150.0, "pending", new Date().toISOString())
      .run();
    
    const result = await env.ORDERS_DB.prepare(
      "SELECT * FROM orders WHERE order_id = ?"
    )
      .bind("ord-123")
      .first();
    
    expect(result).not.toBeNull();
    expect(result!.customer_id).toBe("cust-456");
    expect(result!.total).toBe(150.0);
  });
  
  it("returns null for nonexistent order", async () => {
    const result = await env.ORDERS_DB.prepare(
      "SELECT * FROM orders WHERE order_id = ?"
    )
      .bind("nonexistent")
      .first();
    
    expect(result).toBeNull();
  });
});

Running Tests

# Run all tests
npx wrangler vitest run

<span class="hljs-comment"># Watch mode during development
npx wrangler vitest

<span class="hljs-comment"># Run with coverage
npx wrangler vitest run --coverage

Configure in package.json:

{
  "scripts": {
    "test": "wrangler vitest run",
    "test:watch": "wrangler vitest",
    "test:coverage": "wrangler vitest run --coverage"
  }
}

CI Configuration

# .github/workflows/workers-tests.yml
name: Cloudflare Workers Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests
        run: npm test
        env:
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Miniflare Standalone (Alternative)

For simpler setups without Wrangler integration:

// test/miniflare.test.ts
import { Miniflare } from "miniflare";
import { describe, it, expect, beforeAll, afterAll } from "vitest";

let mf: Miniflare;

beforeAll(async () => {
  mf = new Miniflare({
    modules: true,
    script: `
      export default {
        async fetch(request, env) {
          const url = new URL(request.url);
          if (url.pathname === '/hello') {
            return new Response('Hello, World!');
          }
          return new Response('Not Found', { status: 404 });
        }
      }
    `,
  });
});

afterAll(async () => {
  await mf.dispose();
});

describe("Worker with Miniflare", () => {
  it("responds to /hello", async () => {
    const response = await mf.dispatchFetch("http://example.com/hello");
    expect(response.status).toBe(200);
    expect(await response.text()).toBe("Hello, World!");
  });
});

Summary

Testing Cloudflare Workers effectively requires using the actual Workers runtime, not a Node.js simulation:

  1. @cloudflare/vitest-pool-workers — runs tests inside the Workers runtime; the recommended approach
  2. SELF.fetch() — send requests to your own worker in tests
  3. env from cloudflare:test — access KV, D1, R2 bindings directly in tests
  4. Miniflare standalone — useful for simple scenarios or legacy setups

The key advantage of wrangler vitest over Miniflare-only: your tests run in the same V8 isolate as production, so runtime-specific bugs (missing globals, Workers-only API behavior) surface in tests rather than in production.

Read more