Testing of Workers in Local: Miniflare, Vitest and Wrangler Dev
A complete workflow for testing Workers locally with Miniflare and Vitest: handler unit tests, integration tests with simulated bindings and debugging with wrangler dev without deployment on Cloudflare.
The Problem of Testing at the Edge
Testing a Cloudflare Worker presents unique challenges compared to traditional Node.js testing.
The Workers runtime is not Node.js: it exposes a subset of the Web Platform APIs, it does not
filesystem access, does not use require() and has a different execution model.
This means that standard runner tests like Jest with its environment jsdom
o node they do not correctly simulate the real execution environment.
The Cloudflare ecosystem solution is Miniflare, a simulator local of the Workers runtime built on workerd. Miniflare exhibits the same APIs you find in production (KV, R2, D1, Durable Objects, Cache API, bindings) in a local environment, without requiring a connection to Cloudflare.
What You Will Learn
- Project setup with Vitest + Miniflare (wrangler.toml configuration)
- Unit test of fetch handler with simulated requests
- Testing KV, R2 and D1 with in-memory store
- Durable Objects testing with simulated sessions
- Mocking fetch() to isolate tests from external services
- Wrangler dev for interactive development and debugging with inspector
- CI/CD with GitHub Actions: automatic pre-deploy testing
Setup: Vitest + @cloudflare/vitest-pool-workers
Since 2024, Cloudflare has made available @cloudflare/vitest-pool-workers,
a Vitest pool that uses workerd as the environment. This replaces the approach
previous based on Miniflare as a standalone library - now everything is integrated
in the Vitest workflow.
# Installa le dipendenze di sviluppo
npm install --save-dev vitest @cloudflare/vitest-pool-workers
# Struttura progetto consigliata
my-worker/
src/
index.ts # Entry point Worker
handlers/
users.ts
products.ts
utils/
auth.ts
validation.ts
test/
index.test.ts
handlers/
users.test.ts
integration/
api.test.ts
wrangler.toml
vitest.config.ts
tsconfig.json
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Usa il pool Cloudflare invece dell'ambiente Node.js
pool: '@cloudflare/vitest-pool-workers',
poolOptions: {
workers: {
// Punta al wrangler.toml del tuo Worker
wrangler: { configPath: './wrangler.toml' },
},
},
},
});
# wrangler.toml - configurazione che viene letta dal pool di test
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
# Binding KV usato nei test
[[kv_namespaces]]
binding = "USERS_KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Importante: in test, il pool usa automaticamente KV in-memory
# D1 Database
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
# R2 Bucket
[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "my-bucket"
# Variabili d'ambiente
[vars]
ENVIRONMENT = "test"
API_VERSION = "v1"
Unit Test: Basic Fetch Handler
The most common test is to verify that the fetch handler responds correctly to different types of requests. With the Cloudflare pool, the test runs in the workerd environment real.
// test/index.test.ts
import { describe, it, expect } from 'vitest';
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import worker from '../src/index';
// "env" fornisce i binding configurati in wrangler.toml (in-memory per i test)
// "SELF" e il Worker corrente, utile per integration test
describe('Worker fetch handler', () => {
it('returns 200 for GET /', async () => {
const request = new Request('http://example.com/');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
// Attendi il completamento dei waitUntil()
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
});
it('returns 404 for unknown routes', async () => {
const request = new Request('http://example.com/unknown-route');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(404);
});
it('returns 405 for POST to GET-only endpoint', async () => {
const request = new Request('http://example.com/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Test' }),
headers: { 'Content-Type': 'application/json' },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(405);
});
it('returns JSON with correct Content-Type', async () => {
const request = new Request('http://example.com/api/health');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.headers.get('Content-Type')).toContain('application/json');
const body = await response.json();
expect(body).toHaveProperty('status', 'ok');
});
});
Testing with KV Bindings In-Memory
The Cloudflare pool simulates KV with an in-memory store that resets between tests. You can prepopulate the store before testing and verify that the Worker reads and writes correctly.
// src/handlers/users.ts - il codice da testare
export async function handleGetUser(
userId: string,
env: Env
): Promise<Response> {
const user = await env.USERS_KV.get(`user:${userId}`);
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json(JSON.parse(user));
}
export async function handleCreateUser(
request: Request,
env: Env
): Promise<Response> {
const body = await request.json() as { name: string; email: string };
if (!body.name || !body.email) {
return Response.json({ error: 'name and email required' }, { status: 400 });
}
const userId = crypto.randomUUID();
const user = { id: userId, ...body, createdAt: Date.now() };
await env.USERS_KV.put(`user:${userId}`, JSON.stringify(user));
return Response.json(user, { status: 201 });
}
interface Env {
USERS_KV: KVNamespace;
}
// test/handlers/users.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { env } from 'cloudflare:test';
import { handleGetUser, handleCreateUser } from '../../src/handlers/users';
describe('User handlers', () => {
// Prepopola KV prima di ogni test
beforeEach(async () => {
await env.USERS_KV.put('user:test-id-123', JSON.stringify({
id: 'test-id-123',
name: 'Mario Rossi',
email: 'mario@example.com',
createdAt: 1700000000000,
}));
});
describe('GET user', () => {
it('returns user when found', async () => {
const response = await handleGetUser('test-id-123', env);
expect(response.status).toBe(200);
const body = await response.json() as { id: string; name: string };
expect(body.id).toBe('test-id-123');
expect(body.name).toBe('Mario Rossi');
});
it('returns 404 when user not found', async () => {
const response = await handleGetUser('non-existent', env);
expect(response.status).toBe(404);
const body = await response.json() as { error: string };
expect(body.error).toBe('User not found');
});
});
describe('POST user', () => {
it('creates user and returns 201', async () => {
const request = new Request('http://example.com/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Luca Bianchi', email: 'luca@example.com' }),
});
const response = await handleCreateUser(request, env);
expect(response.status).toBe(201);
const body = await response.json() as { id: string; name: string };
expect(body.name).toBe('Luca Bianchi');
expect(body.id).toBeTruthy();
// Verifica che sia stato effettivamente salvato in KV
const stored = await env.USERS_KV.get(`user:${body.id}`);
expect(stored).not.toBeNull();
});
it('returns 400 when name is missing', async () => {
const request = new Request('http://example.com/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'only-email@example.com' }),
});
const response = await handleCreateUser(request, env);
expect(response.status).toBe(400);
});
});
});
Testing with D1 (SQLite at the Edge)
D1 is Cloudflare's SQLite database. The test pool creates a database in-memory for each run. You can run migration and seed before testing.
// test/integration/d1.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { env } from 'cloudflare:test';
describe('D1 database operations', () => {
// Crea lo schema prima dei test
beforeAll(async () => {
await env.DB.exec(`
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT,
created_at INTEGER DEFAULT (unixepoch())
)
`);
// Seed con dati di test
await env.DB.prepare(
'INSERT INTO products (id, name, price, category) VALUES (?, ?, ?, ?)'
).bind('prod-1', 'Widget Pro', 29.99, 'widgets').run();
await env.DB.prepare(
'INSERT INTO products (id, name, price, category) VALUES (?, ?, ?, ?)'
).bind('prod-2', 'Gadget Plus', 49.99, 'gadgets').run();
});
it('retrieves all products', async () => {
const result = await env.DB.prepare('SELECT * FROM products').all();
expect(result.results).toHaveLength(2);
});
it('filters products by category', async () => {
const result = await env.DB
.prepare('SELECT * FROM products WHERE category = ?')
.bind('widgets')
.all();
expect(result.results).toHaveLength(1);
expect(result.results[0]).toMatchObject({ name: 'Widget Pro' });
});
it('inserts a new product', async () => {
await env.DB.prepare(
'INSERT INTO products (id, name, price, category) VALUES (?, ?, ?, ?)'
).bind('prod-3', 'New Item', 9.99, 'misc').run();
const result = await env.DB.prepare(
'SELECT * FROM products WHERE id = ?'
).bind('prod-3').first();
expect(result).toMatchObject({ name: 'New Item', price: 9.99 });
});
});
Mocking fetch() to Isolate Tests
The Workers runtime exposes a function fetch() global for HTTP calls.
In testing, you want to replace this with a mock to not make real calls to
external services. Miniflare supports mock via fetchMock.
// test/integration/api.test.ts - Mocking di fetch esterno
import { describe, it, expect, vi, afterEach } from 'vitest';
import { env, createExecutionContext, waitOnExecutionContext, fetchMock } from 'cloudflare:test';
import worker from '../../src/index';
// Abilita il mock di fetch prima di tutti i test del file
fetchMock.activate();
fetchMock.disableNetConnect(); // Blocca connessioni reali
afterEach(() => {
fetchMock.assertNoPendingInterceptors();
});
describe('External API integration', () => {
it('proxies weather API with caching', async () => {
// Configura il mock: quando il Worker chiama questo URL, ritorna questa risposta
fetchMock
.get('https://api.weather.example.com')
.intercept({ path: '/v1/current?city=Milan' })
.reply(200, {
city: 'Milan',
temperature: 18,
condition: 'Cloudy',
}, {
headers: { 'Content-Type': 'application/json' },
});
const request = new Request('http://my-worker.example.com/weather?city=Milan');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
const body = await response.json() as { city: string; temperature: number };
expect(body.city).toBe('Milan');
expect(body.temperature).toBe(18);
});
it('handles upstream API errors gracefully', async () => {
fetchMock
.get('https://api.weather.example.com')
.intercept({ path: '/v1/current?city=Unknown' })
.reply(503, { error: 'Service Unavailable' });
const request = new Request('http://my-worker.example.com/weather?city=Unknown');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
// Il Worker deve gestire l'errore e ritornare una risposta coerente
expect(response.status).toBe(503);
const body = await response.json() as { error: string };
expect(body.error).toContain('upstream');
});
});
Authentication and Headers Test
// test/handlers/auth.test.ts
import { describe, it, expect } from 'vitest';
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import worker from '../../src/index';
describe('Authentication middleware', () => {
it('rejects requests without Authorization header', async () => {
const request = new Request('http://example.com/api/protected', {
method: 'GET',
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(401);
const body = await response.json() as { error: string };
expect(body.error).toContain('unauthorized');
});
it('rejects requests with invalid token', async () => {
const request = new Request('http://example.com/api/protected', {
headers: { 'Authorization': 'Bearer invalid-token-xyz' },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(403);
});
it('allows requests with valid API key from KV', async () => {
// Prepopola KV con un API key valido
await env.USERS_KV.put('apikey:valid-api-key-abc', JSON.stringify({
userId: 'user-1',
tier: 'pro',
expiresAt: Date.now() + 3600000,
}));
const request = new Request('http://example.com/api/protected', {
headers: { 'Authorization': 'Bearer valid-api-key-abc' },
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
});
});
Integration Test with SELF
SELF is a reference to the current Worker that allows you to do
HTTP requests as an external client would, useful for end-to-end testing
of the entire router.
// test/integration/e2e.test.ts
import { describe, it, expect } from 'vitest';
import { SELF } from 'cloudflare:test';
// SELF.fetch() chiama il Worker come se fosse una richiesta HTTP reale
// Passa per l'intero stack: middleware, routing, handler
describe('End-to-end API flow', () => {
it('full user CRUD flow', async () => {
// 1. Crea un utente
const createResponse = await SELF.fetch('http://example.com/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test User', email: 'test@example.com' }),
});
expect(createResponse.status).toBe(201);
const created = await createResponse.json() as { id: string; name: string };
const userId = created.id;
// 2. Leggi l'utente
const getResponse = await SELF.fetch(`http://example.com/api/users/${userId}`);
expect(getResponse.status).toBe(200);
const fetched = await getResponse.json() as { name: string };
expect(fetched.name).toBe('Test User');
// 3. Aggiorna l'utente
const updateResponse = await SELF.fetch(`http://example.com/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Updated User' }),
});
expect(updateResponse.status).toBe(200);
// 4. Elimina l'utente
const deleteResponse = await SELF.fetch(`http://example.com/api/users/${userId}`, {
method: 'DELETE',
});
expect(deleteResponse.status).toBe(204);
// 5. Verifica che non esista più
const notFoundResponse = await SELF.fetch(`http://example.com/api/users/${userId}`);
expect(notFoundResponse.status).toBe(404);
});
});
Wrangler Dev: Interactive Development
wrangler dev starts a local server that simulates the Workers runtime
complete, including all bindings. From 2024, use workerd directly, then
the environment is identical to production.
# Avvia il server di sviluppo locale
npx wrangler dev
# Con binding remoti (usa i dati reali di KV/D1 dall'account Cloudflare)
npx wrangler dev --remote
# Con porta specifica e inspector per Chrome DevTools
npx wrangler dev --port 8787 --inspector-port 9229
# Con variabili d'ambiente da file .dev.vars
# .dev.vars (non committare in git!)
# API_SECRET=my-local-secret
# DATABASE_URL=postgresql://localhost:5432/mydb
npx wrangler dev
# wrangler.toml - configurazione per sviluppo locale
[dev]
port = 8787
local_protocol = "http"
# upstream = "https://my-staging-api.example.com" # Proxy verso staging
# KV locale: usa --persist per salvare i dati tra restart
# npx wrangler dev --persist
# I dati vengono salvati in .wrangler/state/
# D1 locale: crea un file SQLite locale
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# In locale, wrangler crea automaticamente .wrangler/state/v3/d1/
# Secrets in locale: usa il file .dev.vars
# [vars] nel wrangler.toml e per valori non segreti
Differences between wrangler dev --local and --remote
- --local (default): All bindings are in-memory/local.
Fast, zero costs, work offline. Data does not persist between restarts
(unless
--persist). - --remote: The code runs locally but the bindings (KV, D1, R2) they point to real data from your Cloudflare account. Useful for testing with real data, but requires internet connection and consumes quota.
CI/CD: GitHub Actions for Automatic Testing
# .github/workflows/test.yml
name: Test Cloudflare Workers
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npx tsc --noEmit
- name: Run unit and integration tests
run: npx vitest run --reporter=verbose
- name: Upload coverage
uses: codecov/codecov-action@v4
if: always()
with:
token: ${{ secrets.CODECOV_TOKEN }}
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Deploy to Cloudflare Workers
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Package.json: Recommended Testing Scripts
{
"scripts": {
"dev": "wrangler dev",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext .ts",
"build": "wrangler deploy --dry-run",
"deploy": "wrangler deploy",
"deploy:staging": "wrangler deploy --env staging"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.5.0",
"@cloudflare/workers-types": "^4.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0",
"wrangler": "^3.50.0"
}
}
Common Anti-Patterns in Workers Testing
Mistakes to Avoid
-
Don't wait waitOnExecutionContext(): I
ctx.waitUntil()they continue after the fetch handler returns. Withoutawait waitOnExecutionContext(ctx)in tests, asynchronous operations (such as saving to KV) may not complete before assertions. - Using Jest instead of Vitest: Jest does not natively support pooling Cloudflare. If you are migrating from Jest, use Vitest's compatibility mode.
-
Test with mutable global state: Remember that the same isolates
can handle multiple requests. Tests must be independent: use
beforeEachto reset the KV/D1 state. -
Real HTTP calls in tests: USA
fetchMockefetchMock.disableNetConnect()to make sure the tests are deterministic and do not depend on external services.
Conclusions and Next Steps
With Vitest + @cloudflare/vitest-pool-workers, you have a
Professional testing workflow for Workers running in the real workerd environment,
eliminates the need to deploy to verify binding behavior
and integrates seamlessly with CI/CD. The setup cost is low, the feedback
it's fast, and the tests are faithful to the production environment.
Next Articles in the Series
- Article 10: Full-Stack Architectures at the Edge — Case Study by Zero to Production: a complete application with Workers, D1, R2, authentication JWT and automated CI/CD.







