Salta al contenuto principale

09 - Testing di Workers in Local: Miniflare, Vitest e Wrangler Dev

Edge Computing 13 min Testing di Workers in Local: Miniflare, Vitest e Wrangler Dev Un workflow completo per testare Workers localmente con Miniflare e...

Cosa ne pensi di questo articolo?

09 - Testing di Workers in Local: Miniflare, Vitest e Wrangler Dev
Edge ComputingFoto:Ruben BoekelooPexels

In Breve

Edge Computing 13 min Testing di Workers in Local: Miniflare, Vitest e Wrangler Dev Un workflow completo per testare Workers localmente con Miniflare e...

Edge Computing 13 min

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.

Miniflare Vitest Wrangler Dev Edge Testing Workers Unit Test

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. Senza await 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 beforeEach per resettare lo stato KV/D1.
  • Chiamate HTTP reali nei test: Usa fetchMock e fetchMock.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

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti