Testování pracovníků v Local: Miniflare, Vitest a Wrangler Dev
Kompletní pracovní postup pro místní testování pracovníků pomocí Miniflare a Vitest: testy manipulačních jednotek, integrační testy se simulovanými vazbami a ladění s wrangler dev bez nasazení na Cloudflare.
Problém testování na okraji
Testování Cloudflare Worker představuje jedinečné výzvy ve srovnání s tradičním testováním Node.js.
Runtime Workers není Node.js: odhaluje podmnožinu rozhraní API webové platformy,
přístup k souborovému systému, nepoužívá require() a má jiný model provedení.
To znamená, že standardní běžci testují jako Jest se svým prostředím jsdom
o uzel nesimulují správně skutečné prostředí provádění.
Ekosystémové řešení Cloudflare je Miniflare, simulátor local runtime Workers postavené na dělník. Miniflare ukazuje totéž API, která najdete v produkci (KV, R2, D1, Durable Objects, Cache API, vazby) v místní prostředí, bez nutnosti připojení ke Cloudflare.
Co se naučíte
- Nastavení projektu pomocí Vitest + Miniflare (konfigurace wrangler.toml)
- Unit test obslužného programu načítání se simulovanými požadavky
- Testování KV, R2 a D1 s úložištěm v paměti
- Testování odolných objektů se simulovanými relacemi
- Mocking fetch() k izolaci testů od externích služeb
- Wrangler dev pro interaktivní vývoj a ladění s inspektorem
- CI/CD s akcemi GitHub: automatické testování před nasazením
Nastavení: Vitest + @cloudflare/vitest-pool-workers
Od roku 2024 zpřístupnil Cloudflare @cloudflare/vitest-pool-workers,
fond Vitest, který jako prostředí používá workerd. Toto nahrazuje přístup
předchozí založené na Miniflare jako samostatné knihovně - nyní je vše integrováno
v pracovním postupu 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: Basic Fetch Handler
Nejběžnějším testem je ověření, zda obslužná rutina načítání reaguje správně na různé typy žádostí. S fondem Cloudflare probíhá test v pracovním prostředí skutečný.
// 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');
});
});
Testování s KV Bindings In-Memory
Fond Cloudflare simuluje KV s úložištěm v paměti, které se mezi testy resetuje. Před testováním můžete předvyplnit úložiště a ověřit, že Worker čte a zapisuje správně.
// 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);
});
});
});
Testování s D1 (SQLite at the Edge)
D1 je SQLite databáze Cloudflare. Testovací fond vytvoří databázi v paměti pro každý běh. Před testováním můžete spustit migraci a seed.
// 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() za účelem izolace testů
Runtime Workers zpřístupňuje funkci fetch() globální pro volání HTTP.
Při testování to chcete nahradit simulací, na kterou nebudete volat
externí služby. Miniflare podporuje 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');
});
});
Autentizace a test záhlaví
// 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);
});
});
Integrační test se SEBE
SELF je odkaz na aktuálního Pracovníka, který vám to umožňuje
HTTP požadavky jako externí klient, užitečné pro end-to-end testování
celého routeru.
// 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 spustí místní server, který simuluje běhové prostředí Workers
kompletní včetně všech vazeb. Od roku 2024 používejte přímo workerd
prostředí je totožné s výrobou.
# 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
Rozdíly mezi wrangler dev --local a --remote
- --local (výchozí): Všechny vazby jsou v paměti/místní.
Rychlé, nulové náklady, práce offline. Data mezi restarty nezůstávají
(pokud
--persist). - --vzdálený: Kód běží lokálně, ale vazby (KV, D1, R2) ukazují na skutečná data z vašeho účtu Cloudflare. Užitečné pro testování s reálná data, ale vyžaduje připojení k internetu a spotřebovává kvótu.
CI/CD: Akce GitHubu pro automatické testování
# .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: Doporučené testovací skripty
{
"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"
}
}
Běžné anti-vzorce při testování pracovníků
Chybám, kterým je třeba se vyhnout
-
Nečekejte waitOnExecutionContext(): I
ctx.waitUntil()pokračují poté, co se obsluha aportu vrátí. Bezawait waitOnExecutionContext(ctx)v testech se asynchronní operace (jako je ukládání do KV) nemusí dokončit před tvrzeními. - Použití Jest místo Vitest: Jest nativně nepodporuje sdružování mračna. Pokud migrujete z Jestu, použijte režim kompatibility Vitest.
-
Test s proměnlivým globálním stavem: Pamatujte, že stejné izoluje
dokáže vyřídit více požadavků. Testy musí být nezávislé: použití
beforeEachpro resetování stavu KV/D1. -
Skutečná volání HTTP v testech: USA
fetchMockefetchMock.disableNetConnect()abyste se ujistili, že testy jsou deterministické a nejsou závislé na externích službách.
Závěry a další kroky
S Vitest + @cloudflare/vitest-pool-workers, máte a
Profesionální testovací pracovní postup pro pracovníky běžící v reálném pracovním prostředí,
eliminuje potřebu nasazení pro ověření chování vazby
a hladce se integruje s CI/CD. Náklady na nastavení jsou nízké, zpětná vazba
je to rychlé a testy jsou věrné produkčnímu prostředí.
Další články v seriálu
- Článek 10: Full-Stack Architectures at the Edge — Případová studie od Zero to Production: kompletní aplikace s Workers, D1, R2, autentizace JWT a automatizované CI/CD.







