Uçta Test Sorunu

Cloudflare Worker'ı test etmek, geleneksel Node.js testine kıyasla benzersiz zorluklar sunar. Workers çalışma zamanı Node.js değildir: Web Platformu API'lerinin bir alt kümesini açığa çıkarır, dosya sistemi erişimi, kullanılmaz require() ve farklı bir yürütme modeline sahiptir. Bu, Jest gibi standart koşucunun kendi ortamıyla test ettiği anlamına gelir jsdom o düğüm gerçek yürütme ortamını doğru şekilde simüle etmezler.

Cloudflare ekosistem çözümü Mini alev, bir simülatör üzerine inşa edilen Workers çalışma zamanının yereli çalışan. Mini flare de aynı şeyi sergiliyor Üretimde bulduğunuz API'ler (KV, R2, D1, Dayanıklı Nesneler, Önbellek API'si, bağlamalar) Cloudflare bağlantısı gerektirmeden yerel ortam.

Ne Öğreneceksiniz

  • Vitest + Miniflare ile proje kurulumu (wrangler.toml konfigürasyonu)
  • Simüle edilmiş isteklerle getirme işleyicisinin birim testi
  • KV, R2 ve D1'i bellek içi depoyla test etme
  • Simüle edilmiş oturumlarla Dayanıklı Nesneler testi
  • Testleri harici hizmetlerden yalıtmak için fetch() ile alay etme
  • Denetçiyle etkileşimli geliştirme ve hata ayıklama için Wrangler geliştiricisi
  • GitHub Eylemlerini içeren CI/CD: otomatik dağıtım öncesi testi

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

Cloudflare 2024'ten beri kullanıma sunuldu @cloudflare/vitest-pool-workers, Ortam olarak Workd'ü kullanan bir Vitest havuzu. Bu yaklaşımın yerine geçer önceden bağımsız bir kitaplık olarak Miniflare'i temel alıyordu - artık her şey entegre Vitest iş akışında.

# 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"

Birim Testi: Temel Getirme İşleyicisi

En yaygın test, getirme işleyicisinin doğru yanıt verdiğini doğrulamaktır. farklı türdeki isteklere. Cloudflare havuzuyla test, çalışma ortamında yürütülür gerçek.

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

});

KV Bağlamaları Bellek İçi ile Test Etme

Cloudflare havuzu, testler arasında sıfırlanan bir bellek içi depo ile KV'yi simüle eder. Test etmeden önce mağazayı önceden doldurabilir ve Çalışanın okuyup yazdığını doğrulayabilirsiniz. doğru.

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

});

D1 ile test etme (Uçta SQLite)

D1, Cloudflare'in SQLite veritabanıdır. Test havuzu bir veritabanı oluşturur her çalıştırma için bellekte. Test etmeden önce taşıma ve tohumlamayı çalıştırabilirsiniz.

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

});

Testleri Yalıtmak için fetch() ile alay etme

Workers çalışma zamanı bir işlevi kullanıma sunuyor fetch() HTTP çağrıları için global. Test sırasında, gerçek aramalar yapmamak için bunu bir sahte ile değiştirmek istiyorsunuz. dış hizmetler. Miniflare, sahte yolu destekler 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');
  });

});

Kimlik Doğrulama ve Başlık Testi

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

});

SELF ile Entegrasyon Testi

SELF yapmanıza izin veren mevcut Çalışana bir referanstır Harici bir istemcinin HTTP istekleri, uçtan uca testler için faydalıdır tüm yönlendiricinin.

// 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: Etkileşimli Geliştirme

wrangler dev Workers çalışma zamanını simüle eden yerel bir sunucuyu başlatır tüm bağlamalar dahil eksiksiz. 2024'ten itibaren doğrudan Workd'ü kullanın, ardından çevre üretimle aynıdır.

# 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

Wrangler dev --local ve --remote arasındaki farklar

  • --local (varsayılan): Tüm bağlamalar bellek içi/yereldir. Hızlı, sıfır maliyet, çevrimdışı çalışın. Yeniden başlatmalar arasında veriler kalıcı olmuyor (tabii ki --persist).
  • --uzak: Kod yerel olarak çalışır ancak bağlamalar (KV, D1, R2) Cloudflare hesabınızdaki gerçek verilere işaret ederler. İle test etmek için kullanışlıdır gerçek veridir ancak internet bağlantısı gerektirir ve kota tüketir.

CI/CD: Otomatik Test için GitHub Eylemleri

# .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: Önerilen Test Komut Dosyaları

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

İşçi Testlerinde Yaygın Anti-Paternler

Kaçınılması Gereken Hatalar

  • Beklemeyin waitOnExecutionContext(): I ctx.waitUntil() getirme işleyicisi geri döndükten sonra devam ederler. Olmadan await waitOnExecutionContext(ctx) testlerde asenkron işlemler (KV'ye kaydetme gibi) tamamlanmayabilir iddialardan önce.
  • Vitest yerine Jest'i kullanmak: Jest yerel olarak havuzlamayı desteklemiyor Bulut parlaması. Jest'ten geçiş yapıyorsanız Vitest'in uyumluluk modunu kullanın.
  • Değişken küresel durumla test edin: Aynı izolatların olduğunu unutmayın birden fazla isteği karşılayabilir. Testler bağımsız olmalıdır: kullanın beforeEach KV/D1 durumunu sıfırlamak için.
  • Testlerde gerçek HTTP çağrıları: Amerika fetchMock e fetchMock.disableNetConnect() testlerin yapıldığından emin olmak için Belirleyicidir ve dış hizmetlere bağlı değildir.

Sonuçlar ve Sonraki Adımlar

Vitest'le + @cloudflare/vitest-pool-workers, senin bir Gerçek çalışan ortamında çalışan Çalışanlar için profesyonel test iş akışı, bağlama davranışını doğrulamak için konuşlandırma ihtiyacını ortadan kaldırır ve CI/CD ile sorunsuz bir şekilde bütünleşir. Kurulum maliyeti düşüktür, geri bildirim hızlıdır ve testler üretim ortamına sadıktır.

Serideki Sonraki Yazılar

  • Madde 10: Uçta Tam Yığın Mimariler — Örnek Olay İncelemesi Sıfırdan Üretime: İşçiler, D1, R2, kimlik doğrulama ile eksiksiz bir uygulama JWT ve otomatik CI/CD.