Problem testowania na krawędzi

Testowanie pracownika Cloudflare stwarza wyjątkowe wyzwania w porównaniu z tradycyjnym testowaniem Node.js. Środowisko wykonawcze Workers nie jest środowiskiem Node.js: udostępnia podzbiór interfejsów API platformy internetowej, nie dostęp do systemu plików, nie używa require() i ma inny model wykonania. Oznacza to, że standardowy biegacz testuje, podobnie jak Jest, ze swoim środowiskiem jsdom o węzeł nie symulują poprawnie rzeczywistego środowiska wykonawczego.

Rozwiązaniem ekosystemowym Cloudflare jest Miniflara, symulator local wbudowanego środowiska uruchomieniowego Workers pracownik. Miniflare wykazuje to samo Interfejsy API, które znajdziesz w środowisku produkcyjnym (KV, R2, D1, Durable Objects, Cache API, powiązania) w środowisku lokalnym, bez konieczności połączenia z Cloudflare.

Czego się nauczysz

  • Konfiguracja projektu za pomocą Vitest + Miniflare (konfiguracja wrangler.toml)
  • Test jednostkowy modułu obsługi pobierania z symulowanymi żądaniami
  • Testowanie KV, R2 i D1 z magazynem w pamięci
  • Testowanie trwałych obiektów z symulowanymi sesjami
  • Wyśmiewanie funkcji fetch() w celu odizolowania testów od usług zewnętrznych
  • Programista Wranglera do interaktywnego programowania i debugowania za pomocą inspektora
  • CI/CD z akcjami GitHub: automatyczne testowanie przed wdrożeniem

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

Od 2024 roku Cloudflare udostępnia @cloudflare/vitest-pool-workers, pula Vitest, która wykorzystuje proces roboczy jako środowisko. To zastępuje podejście poprzednio oparty na Miniflare jako samodzielnej bibliotece - teraz wszystko jest zintegrowane w przepływie pracy 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 jednostkowy: podstawowa procedura obsługi pobierania

Najczęstszym testem jest sprawdzenie, czy moduł obsługi pobierania reaguje poprawnie na różne rodzaje żądań. W przypadku puli Cloudflare test jest uruchamiany w środowisku roboczym prawdziwy.

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

});

Testowanie z powiązaniami KV w pamięci

Pula Cloudflare symuluje wartość KV za pomocą magazynu w pamięci, który jest resetowany między testami. Możesz wstępnie wypełnić sklep przed testowaniem i sprawdzić, czy pracownik czyta i pisze poprawnie.

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

});

Testowanie z D1 (SQLite na krawędzi)

D1 to baza danych SQLite firmy Cloudflare. Pula testowa tworzy bazę danych w pamięci dla każdego uruchomienia. Przed testowaniem możesz uruchomić migrację i inicjowanie.

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

});

Kpiące fetch() w celu izolowania testów

Środowisko wykonawcze Workers udostępnia funkcję fetch() global dla połączeń HTTP. Podczas testowania chcesz zastąpić to próbą, do której nie można wykonywać prawdziwych połączeń usługi zewnętrzne. Miniflare obsługuje próbne 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 uwierzytelniania i nagłówków

// 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 integracji z SELF

SELF jest odniesieniem do bieżącego Robotnika, który pozwala Ci to zrobić Żądania HTTP, tak jak zrobiłby to klient zewnętrzny, przydatne do kompleksowych testów całego routera.

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

});

Twórca Wranglera: rozwój interaktywny

wrangler dev uruchamia lokalny serwer, który symuluje środowisko wykonawcze Workers kompletny, łącznie ze wszystkimi wiązaniami. W takim razie od 2024 r. korzystaj bezpośrednio z pracownika środowisko jest identyczne z produkcją.

# 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

Różnice pomiędzy wranglerem --local i --remote

  • --local (domyślnie): Wszystkie powiązania są w pamięci/lokalne. Szybko, bez kosztów, pracuj offline. Dane nie są zachowywane pomiędzy ponownymi uruchomieniami (chyba że --persist).
  • --zdalny: Kod działa lokalnie, ale powiązania (KV, D1, R2) wskazują na prawdziwe dane z Twojego konta Cloudflare. Przydatne do testowania z prawdziwe dane, ale wymaga połączenia z Internetem i zużywa miejsce.

CI/CD: Akcje GitHub dotyczące automatycznego testowania

# .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: Zalecane skrypty testowe

{
  "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"
  }
}

Typowe anty-wzorce w testowaniu pracowników

Błędy, których należy unikać

  • Nie czekaj czekajOnExecutionContext(): I ctx.waitUntil() są one kontynuowane po powrocie programu obsługi pobierania. Bez await waitOnExecutionContext(ctx) w testach operacje asynchroniczne (takie jak zapisywanie do KV) mogą nie zostać zakończone przed stwierdzeniami.
  • Używanie Jest zamiast Vitest: Jest natywnie nie obsługuje łączenia plików Cloudflare. Jeśli przeprowadzasz migrację z Jest, użyj trybu zgodności Vitest.
  • Test ze zmiennym stanem globalnym: Pamiętaj, że to samo izoluje może obsłużyć wiele żądań. Testy muszą być niezależne: użyj beforeEach aby zresetować stan KV/D1.
  • Prawdziwe wywołania HTTP w testach: USA fetchMock e fetchMock.disableNetConnect() aby upewnić się, że testy są deterministyczne i niezależne od usług zewnętrznych.

Wnioski i dalsze kroki

Z Vitestem + @cloudflare/vitest-pool-workers, masz Profesjonalny przepływ pracy testowej dla Workerów działających w rzeczywistym środowisku roboczym, eliminuje potrzebę wdrażania w celu sprawdzenia zachowania powiązania i bezproblemowo integruje się z CI/CD. Koszt instalacji jest niski, opinie jest szybki, a testy są wierne środowisku produkcyjnemu.

Następne artykuły z serii

  • Artykuł 10: Architektury Full-Stack na krawędzi — studium przypadku autorstwa Zero to Production: kompletna aplikacja z uwierzytelnianiem Workers, D1, R2 JWT i automatyczne CI/CD.