Testing Cloudflare Workers Locally with Miniflare and Wrangler

Testing Cloudflare Workers Locally with Miniflare and Wrangler

Cloudflare Workers run in V8 isolates, not Node.js. That difference means standard testing approaches break: node:fs, node:http, and most Node.js built-ins are unavailable. Miniflare solves this by implementing the Workers runtime APIs locally — KV, R2, D1, Durable Objects, Queues — so your tests run in an environment that faithfully matches production.

How Miniflare Works

Miniflare (v3+) is embedded inside Wrangler. It uses workerd, Cloudflare's open-source Workers runtime, to execute code in a real V8 environment. Unlike mocking, this means:

  • Web standard APIs (fetch, Request, Response, Headers, URL) work exactly as in production
  • KV reads and writes persist in-memory during a test run
  • D1 runs real SQLite via better-sqlite3
  • Durable Objects run with actual storage semantics

Project Setup

npm create cloudflare@latest my-worker -- --type hello-world
<span class="hljs-built_in">cd my-worker

The scaffolded project includes wrangler.toml, the worker source, and basic TypeScript support. Add Vitest with the Workers pool:

npm install -D vitest @cloudflare/vitest-pool-workers

Vitest Configuration for Workers

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

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

The defineWorkersConfig function wraps Vitest's standard config and injects the Workers pool. Your wrangler.toml is used as the source of truth for bindings — KV namespaces, D1 databases, R2 buckets, and environment variables defined there are automatically available in tests.

Writing Your First Worker Test

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

    if (url.pathname === '/') {
      return new Response('Hello, Worker!', { status: 200 })
    }

    if (url.pathname === '/health') {
      return Response.json({ status: 'ok', timestamp: Date.now() })
    }

    return new Response('Not found', { status: 404 })
  },
}
// src/index.test.ts
import { describe, it, expect } from 'vitest'
import { SELF } from 'cloudflare:test'

describe('Worker routes', () => {
  it('returns hello on /', async () => {
    const response = await SELF.fetch('http://example.com/')
    expect(response.status).toBe(200)
    expect(await response.text()).toBe('Hello, Worker!')
  })

  it('returns JSON on /health', async () => {
    const response = await SELF.fetch('http://example.com/health')
    expect(response.status).toBe(200)
    const body = await response.json() as { status: string }
    expect(body.status).toBe('ok')
  })

  it('returns 404 for unknown routes', async () => {
    const response = await SELF.fetch('http://example.com/unknown')
    expect(response.status).toBe(404)
  })
})

SELF is a special binding provided by cloudflare:test that represents your own worker. It dispatches requests through the real Workers runtime.

Testing KV Bindings

Configure a KV namespace in wrangler.toml:

[[kv_namespaces]]
binding = "CACHE"
id = "local-cache"

Worker code:

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

    if (request.method === 'GET' && url.pathname.startsWith('/cache/')) {
      const key = url.pathname.replace('/cache/', '')
      const value = await env.CACHE.get(key)
      if (!value) return new Response('Not found', { status: 404 })
      return new Response(value)
    }

    if (request.method === 'PUT' && url.pathname.startsWith('/cache/')) {
      const key = url.pathname.replace('/cache/', '')
      const value = await request.text()
      await env.CACHE.put(key, value, { expirationTtl: 3600 })
      return new Response('OK')
    }

    return new Response('Method not allowed', { status: 405 })
  },
}

Tests with KV:

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

describe('KV cache', () => {
  beforeEach(async () => {
    // env.CACHE is the real Miniflare KV namespace
    // Clear state between tests
    const list = await env.CACHE.list()
    for (const key of list.keys) {
      await env.CACHE.delete(key.name)
    }
  })

  it('stores and retrieves values', async () => {
    await SELF.fetch('http://example.com/cache/greeting', {
      method: 'PUT',
      body: 'hello world',
    })

    const response = await SELF.fetch('http://example.com/cache/greeting')
    expect(response.status).toBe(200)
    expect(await response.text()).toBe('hello world')
  })

  it('returns 404 for missing keys', async () => {
    const response = await SELF.fetch('http://example.com/cache/missing')
    expect(response.status).toBe(404)
  })

  it('can seed KV directly and read via API', async () => {
    // Seed via the binding directly (bypassing HTTP)
    await env.CACHE.put('seeded-key', 'seeded-value')

    const response = await SELF.fetch('http://example.com/cache/seeded-key')
    expect(await response.text()).toBe('seeded-value')
  })
})

Testing D1 Databases

D1 is Cloudflare's managed SQLite. In tests, Miniflare runs a real SQLite database:

[[d1_databases]]
binding = "DB"
database_name = "my-app"
database_id = "local-db"
migrations_dir = "migrations"
-- migrations/0001_create_users.sql
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  created_at INTEGER NOT NULL DEFAULT (unixepoch())
);

Worker with D1:

// src/users.ts
export async function getUser(db: D1Database, id: number) {
  return db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first()
}

export async function createUser(db: D1Database, email: string, name: string) {
  const result = await db
    .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(email, name)
    .first()
  return result
}
// src/users.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { env } from 'cloudflare:test'
import { getUser, createUser } from './users'

describe('User database operations', () => {
  beforeEach(async () => {
    // Apply migrations
    await env.DB.exec(`
      DROP TABLE IF EXISTS users;
      CREATE TABLE users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL UNIQUE,
        name TEXT NOT NULL,
        created_at INTEGER NOT NULL DEFAULT (unixepoch())
      );
    `)
  })

  it('creates a user', async () => {
    const user = await createUser(env.DB, 'alice@example.com', 'Alice')
    expect(user).toMatchObject({ email: 'alice@example.com', name: 'Alice' })
    expect(user?.id).toBeTypeOf('number')
  })

  it('retrieves a user by id', async () => {
    const created = await createUser(env.DB, 'bob@example.com', 'Bob')
    const retrieved = await getUser(env.DB, created!.id as number)
    expect(retrieved).toMatchObject({ email: 'bob@example.com', name: 'Bob' })
  })

  it('returns null for non-existent user', async () => {
    const user = await getUser(env.DB, 999999)
    expect(user).toBeNull()
  })

  it('enforces unique email constraint', async () => {
    await createUser(env.DB, 'unique@example.com', 'First')
    await expect(
      createUser(env.DB, 'unique@example.com', 'Second')
    ).rejects.toThrow()
  })
})

Testing Durable Objects

Durable Objects have per-instance state and can coordinate across requests. Testing them requires a different setup:

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
// src/counter.ts
export class Counter implements DurableObject {
  private count: number = 0

  constructor(private state: DurableObjectState) {}

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)

    if (url.pathname === '/increment') {
      this.count++
      await this.state.storage.put('count', this.count)
      return Response.json({ count: this.count })
    }

    if (url.pathname === '/get') {
      const stored = await this.state.storage.get<number>('count') ?? 0
      return Response.json({ count: stored })
    }

    return new Response('Not found', { status: 404 })
  }
}
// src/counter.test.ts
import { describe, it, expect } from 'vitest'
import { SELF, runDurableObjectAlarm } from 'cloudflare:test'

describe('Counter Durable Object', () => {
  it('increments count', async () => {
    const id = 'test-counter'

    const inc1 = await SELF.fetch(`http://example.com/counter/${id}/increment`, {
      method: 'POST',
    })
    expect((await inc1.json() as { count: number }).count).toBe(1)

    const inc2 = await SELF.fetch(`http://example.com/counter/${id}/increment`, {
      method: 'POST',
    })
    expect((await inc2.json() as { count: number }).count).toBe(2)
  })

  it('persists count across requests', async () => {
    const id = 'persistence-test'

    await SELF.fetch(`http://example.com/counter/${id}/increment`, { method: 'POST' })
    await SELF.fetch(`http://example.com/counter/${id}/increment`, { method: 'POST' })

    const get = await SELF.fetch(`http://example.com/counter/${id}/get`)
    expect((await get.json() as { count: number }).count).toBe(2)
  })
})

Using Wrangler Test Environments

For integration-style tests against a running Wrangler dev server:

// test/integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { execSync, spawn } from 'node:child_process'

describe('Integration: Wrangler dev server', () => {
  let server: ReturnType<typeof spawn>
  const baseUrl = 'http://localhost:8787'

  beforeAll(async () => {
    server = spawn('npx', ['wrangler', 'dev', '--local', '--port', '8787'], {
      stdio: 'pipe',
      env: { ...process.env },
    })

    // Wait for the server to start
    await new Promise<void>((resolve) => {
      server.stdout?.on('data', (data: Buffer) => {
        if (data.toString().includes('Ready on')) resolve()
      })
    })
  })

  afterAll(() => {
    server.kill()
  })

  it('worker responds to requests', async () => {
    const response = await fetch(`${baseUrl}/`)
    expect(response.status).toBe(200)
  })
})

For most unit tests, prefer the Vitest Workers pool over a running Wrangler server — it is faster, isolated, and does not require a free port.

Environment Variables in Tests

Define test-specific environment variables using wrangler.toml environments:

[env.test]
vars = { API_URL = "https://test-api.example.com", LOG_LEVEL = "debug" }

[[env.test.kv_namespaces]]
binding = "CACHE"
id = "test-cache"

Run tests against the test environment:

WRANGLER_ENV=test npx vitest

Or configure it in vitest.config.ts:

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

CI Configuration

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Run Worker tests
        run: npx vitest run

      - name: Deploy to Cloudflare (main only)
        if: github.ref == 'refs/heads/main'
        run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Common Pitfalls

Node.js built-ins don't work in Workers. Code that imports node:crypto, node:buffer, or node:path will fail. Use Web APIs equivalents (crypto.subtle, TextEncoder, URL) or add polyfills via nodejs_compat compatibility flag:

compatibility_flags = ["nodejs_compat"]

Don't use global or process. Workers run in V8 isolates with no Node.js globals. Use globalThis where you need the global object.

KV state persists within a test file but not across files. Miniflare creates isolated storage per Vitest worker. Use beforeEach to reset state rather than relying on test order.

Durable Objects need routing. Tests access Durable Objects through your worker's fetch handler, not directly. Make sure your worker has routes that proxy requests to the correct DO instance.

Miniflare and the @cloudflare/vitest-pool-workers package make Workers testing fast and accurate. The combination of real V8 isolates, real SQLite for D1, and real in-memory KV means bugs that would only appear on Cloudflare's infrastructure surface during local testing — keeping your deployment confidence high.

Read more