Problema testării la margine

Testarea unui Cloudflare Worker prezintă provocări unice în comparație cu testarea tradițională Node.js. Runtime-ul Workers nu este Node.js: expune un subset al API-urilor Web Platform, nu acces la sistemul de fișiere, nu folosește require() și are un model de execuție diferit. Aceasta înseamnă că alergătorii standard testează precum Jest cu mediul său jsdom o nodul nu simulează corect mediul real de execuție.

Soluția ecosistemului Cloudflare este Miniflare, un simulator local al runtime-ului Workers construit pe muncitor. Miniflare prezintă la fel API-uri pe care le găsiți în producție (KV, R2, D1, Durable Objects, Cache API, legături) într-un mediu local, fără a necesita o conexiune la Cloudflare.

Ce vei învăța

  • Configurarea proiectului cu Vitest + Miniflare (configurație wrangler.toml)
  • Test unitar al handlerului de preluare cu cereri simulate
  • Testarea KV, R2 și D1 cu stocare în memorie
  • Testarea obiectelor durabile cu sesiuni simulate
  • Batjocură fetch() pentru a izola testele de serviciile externe
  • Dezvoltator Wrangler pentru dezvoltare interactivă și depanare cu inspector
  • CI/CD cu GitHub Actions: testare automată înainte de implementare

Configurare: Vitest + @cloudflare/vitest-pool-workers

Din 2024, Cloudflare a fost disponibil @cloudflare/vitest-pool-workers, un bazin Vitest care folosește workerd ca mediu. Aceasta înlocuiește abordarea anterior bazat pe Miniflare ca bibliotecă autonomă - acum totul este integrat în fluxul de lucru 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"

Test unitar: Handler Fetch de bază

Cel mai frecvent test este de a verifica dacă handlerul de preluare răspunde corect la diferite tipuri de cereri. Cu pool-ul Cloudflare, testul rulează în mediul 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');
  });

});

Testare cu KV Bindings în memorie

Pool-ul Cloudflare simulează KV cu un depozit în memorie care se resetează între teste. Puteți prepopula magazinul înainte de a testa și verifica dacă lucrătorul citește și scrie corect.

// 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);
    });
  });

});

Testare cu D1 (SQLite at the Edge)

D1 este baza de date SQLite a Cloudflare. Pool-ul de testare creează o bază de date în memorie pentru fiecare cursă. Puteți rula migrarea și seed înainte de testare.

// 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 });
  });

});

Batjocură de fetch() pentru a izola testele

Runtime-ul Workers expune o funcție fetch() global pentru apeluri HTTP. În timpul testării, doriți să înlocuiți acest lucru cu o simulare pentru a nu efectua apeluri reale servicii externe. Miniflare acceptă 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');
  });

});

Test de autentificare și anteturi

// 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);
  });

});

Test de integrare cu SELF

SELF este o referință la Lucrătorul actual care vă permite să faceți Solicitările HTTP ca un client extern, utile pentru testarea de la capăt la capăt a întregului 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);
  });

});

Dezvoltator Wrangler: Dezvoltare interactivă

wrangler dev pornește un server local care simulează runtime-ul Workers complet, inclusiv toate legăturile. Din 2024, folosește direct workerd, atunci mediul este identic cu producția.

# 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

Diferențele dintre wrangler dev --local și --remote

  • --local (implicit): Toate legăturile sunt în memorie/locale. Rapid, costuri zero, lucrați offline. Datele nu persistă între reporniri (cu excepția cazului în care --persist).
  • --telecomanda: Codul rulează local, dar legăturile (KV, D1, R2) ele indică date reale din contul tău Cloudflare. Util pentru testare cu date reale, dar necesită conexiune la internet și consumă cotă.

CI/CD: Acțiuni GitHub pentru testarea automată

# .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: Scripturi de testare recomandate

{
  "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-modele comune în testarea lucrătorilor

Greșeli de evitat

  • Nu așteptați waitOnExecutionContext(): I ctx.waitUntil() acestea continuă după ce handlerul de preluare revine. Fără await waitOnExecutionContext(ctx) în teste, este posibil ca operațiunile asincrone (cum ar fi salvarea în KV) să nu se finalizeze inaintea afirmatiilor.
  • Folosind Jest în loc de Vitest: Jest nu acceptă în mod nativ gruparea Cloudflare. Dacă migrați de la Jest, utilizați modul de compatibilitate Vitest.
  • Testare cu stare globală mutabilă: Amintiți-vă că aceleași izolate poate gestiona mai multe cereri. Testele trebuie să fie independente: utilizare beforeEach pentru a reseta starea KV/D1.
  • Apeluri HTTP reale în teste: STATELE UNITE ALE AMERICII fetchMock e fetchMock.disableNetConnect() pentru a se asigura că testele sunt deterministe si nu depind de serviciile externe.

Concluzii și pașii următori

Cu Vitest + @cloudflare/vitest-pool-workers, ai un Flux de lucru de testare profesională pentru lucrătorii care rulează în mediul de lucru real, elimină necesitatea implementării pentru a verifica comportamentul de legare și se integrează perfect cu CI/CD. Costul de configurare este mic, feedback-ul este rapid, iar testele sunt fidele mediului de producție.

Următoarele articole din serie

  • Articolul 10: Arhitecturi Full-Stack la margine — Studiu de caz de Zero to Production: o aplicație completă cu Workers, D1, R2, autentificare JWT și CI/CD automat.