Testing di Workers in Local: Miniflare, Vitest e Wrangler Dev
Un workflow completo per testare Workers localmente con Miniflare e Vitest: unit test degli handler, integration test con binding simulati e debug con wrangler dev senza deploy su Cloudflare.
Il Problema del Testing sull'Edge
Testare un Cloudflare Worker presenta sfide uniche rispetto al testing Node.js tradizionale.
Il Workers runtime non è Node.js: espone una subset delle Web Platform APIs, non ha
accesso al filesystem, non usa require() e ha un modello di esecuzione diverso.
Questo significa che i test runner standard come Jest con il suo ambiente jsdom
o node non simulano correttamente l'ambiente di esecuzione reale.
La soluzione dell'ecosistema Cloudflare è Miniflare, un simulatore locale del Workers runtime costruito su workerd. Miniflare espone le stesse API che trovi in produzione (KV, R2, D1, Durable Objects, Cache API, bindings) in un ambiente locale, senza richiedere una connessione a Cloudflare.
Cosa Imparerai
- Setup del progetto con Vitest + Miniflare (configurazione wrangler.toml)
- Unit test di fetch handler con richieste simulate
- Test di KV, R2 e D1 con store in-memory
- Test di Durable Objects con session simulate
- Mocking di fetch() per isolare i test da servizi esterni
- Wrangler dev per sviluppo interattivo e debug con inspector
- CI/CD con GitHub Actions: testing automatico pre-deploy
Setup: Vitest + @cloudflare/vitest-pool-workers
Dal 2024, Cloudflare ha reso disponibile @cloudflare/vitest-pool-workers,
un pool di Vitest che usa workerd come environment. Questo sostituisce l'approccio
precedente basato su Miniflare come libreria standalone: ora tutto è integrato
nel workflow Vitest.
# 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: Fetch Handler di Base
Il test più comune è verificare che il fetch handler risponda correttamente a diversi tipi di richiesta. Con il pool Cloudflare, il test gira nell'ambiente workerd reale.
// 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');
});
});
Test con KV Bindings In-Memory
Il pool Cloudflare simula KV con uno store in-memory che si resetta tra i test. Puoi prepopolare lo store prima dei test e verificare che il Worker legga e scriva correttamente.
// 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);
});
});
});
Test con D1 (SQLite all'Edge)
D1 è il database SQLite di Cloudflare. Il pool di test crea un database in-memory per ogni run. Puoi eseguire migration e seed prima dei test.
// 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 di fetch() per Isolare i Test
Il Workers runtime espone una funzione fetch() globale per chiamate HTTP.
Nei test, vuoi sostituire questa con un mock per non effettuare chiamate reali a
servizi esterni. Miniflare supporta il mock tramite 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');
});
});
Test di Autenticazione e Headers
// 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 con SELF
SELF è un riferimento al Worker corrente che permette di fare
richieste HTTP come farebbe un client esterno, utile per test end-to-end
dell'intero 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: Sviluppo Interattivo
wrangler dev avvia un server locale che simula il runtime Workers
completo, inclusi tutti i binding. Dal 2024, usa workerd direttamente, quindi
l'ambiente è identico alla produzione.
# 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
Differenze tra wrangler dev --local e --remote
- --local (default): Tutti i binding sono in-memory/locale.
Veloce, zero costi, lavora offline. I dati non persistono tra restart
(a meno di
--persist). - --remote: Il codice gira in locale ma i binding (KV, D1, R2) puntano ai dati reali del tuo account Cloudflare. Utile per testare con dati reali, ma richiede connessione internet e consuma quota.
CI/CD: GitHub Actions per Testing Automatico
# .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: Script di Testing Consigliati
{
"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"
}
}
Anti-Pattern Comuni nel Testing di Workers
Errori da Evitare
-
Non aspettare waitOnExecutionContext(): I
ctx.waitUntil()continuano dopo che il fetch handler ritorna. Senzaawait waitOnExecutionContext(ctx)nei test, le operazioni asincrone (come il salvataggio in KV) potrebbero non completarsi prima degli assertion. - Usare Jest invece di Vitest: Jest non supporta nativamente il pool Cloudflare. Se stai migrando da Jest, usa la compatibility mode di Vitest.
-
Test con stato globale mutabile: Ricorda che lo stesso isolate
può gestire più richieste. I test devono essere indipendenti: usa
beforeEachper resettare lo stato KV/D1. -
Chiamate HTTP reali nei test: Usa
fetchMockefetchMock.disableNetConnect()per assicurarti che i test siano deterministici e non dipendano da servizi esterni.
Conclusioni e Prossimi Passi
Con Vitest + @cloudflare/vitest-pool-workers, hai a disposizione un
workflow di testing professionale per Workers che gira nell'ambiente workerd reale,
elimina la necessità di deploy per verificare il comportamento dei binding
e integra perfettamente con CI/CD. Il costo di setup è basso, il feedback
è veloce, e i test sono fedeli all'ambiente di produzione.
Prossimi Articoli della Serie
- Articolo 10: Architetture Full-Stack sull'Edge - Case Study da Zero a Produzione: un'applicazione completa con Workers, D1, R2, autenticazione JWT e CI/CD automatizzato.

Commenti
Caricamento commenti...
Accedi per commentare