エッジでのテストの問題

Cloudflare Workerのテストには、従来のNode.jsテストと比較して独特の課題があります。 Workers ランタイムは Node.js ではありません。Web プラットフォーム API のサブセットを公開しますが、そうではありません。 ファイルシステムへのアクセス、使用しません require() 実行モデルが異なります。 これは、標準ランナーがその環境で Jest のようなテストを行うことを意味します。 ジェスダム o ノード 実際の実行環境を正しくシミュレートしません。

Cloudflare エコシステム ソリューションは次のとおりです。 ミニフレア、シミュレータ 構築された Workers ランタイムのローカル 労働者。ミニフレアも同じものを展示 本番環境で使用される API (KV、R2、D1、Durable Objects、Cache API、バインディング) Cloudflareへの接続を必要とせずに、ローカル環境で使用できます。

何を学ぶか

  • Vitest + Miniflare を使用したプロジェクトのセットアップ (wrangler.toml 構成)
  • シミュレートされたリクエストを使用したフェッチ ハンドラーの単体テスト
  • インメモリストアを使用した KV、R2、および D1 のテスト
  • シミュレートされたセッションを使用した永続オブジェクトのテスト
  • fetch() をモックしてテストを外部サービスから分離する
  • インスペクターを使用したインタラクティブな開発とデバッグのための Wrangler dev
  • GitHub Actions を使用した CI/CD: 自動デプロイ前テスト

セットアップ: Vitest + @cloudflare/vitest-pool-workers

2024 年以降、Cloudflare は @cloudflare/vitest-pool-workers、 Workerd を環境として使用する Vitest プール。これはアプローチを置き換えます 以前はスタンドアロン ライブラリとして Miniflare に基づいていましたが、現在はすべてが統合されています 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"

単体テスト: 基本的なフェッチ ハンドラー

最も一般的なテストは、フェッチ ハンドラーが正しく応答することを確認することです。 さまざまな種類のリクエストに対応します。 Cloudflare プールを使用すると、テストはワーカー環境で実行されます 本当の。

// 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 バインディングを使用したメモリ内テスト

Cloudflare プールは、テスト間でリセットされるメモリ内ストアを使用して KV をシミュレートします。 テスト前にストアに事前設定し、ワーカーが読み取りと書き込みを行うことを確認できます。 正しく。

// 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 を使用したテスト (エッジの SQLite)

D1 は Cloudflare の SQLite データベースです。テストプールはデータベースを作成します 実行ごとにメモリ内に保存されます。テスト前に移行とシードを実行できます。

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

});

fetch() をモックしてテストを分離する

Workers ランタイムは関数を公開します fetch() HTTP 呼び出しのグローバル。 テストでは、これをモックに置き換えて、実際の呼び出しを行わないようにする必要があります。 外部サービス。 Miniflare はモックをサポートします 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/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との結合テスト

SELF 現在の Worker への参照です。 外部クライアントと同様の HTTP リクエスト。エンドツーエンドのテストに役立ちます ルーター全体の。

// 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 開発: インタラクティブな開発

wrangler dev Workers ランタイムをシミュレートするローカル サーバーを起動します。 すべてのバインディングを含む完全な状態。 2024 年からは、workerd を直接使用し、その後は 環境は本番環境と同じです。

# 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 と --remote の違い

  • --ローカル (デフォルト): すべてのバインディングはメモリ内/ローカルです。 高速、コストゼロ、オフラインで作業可能。再起動間でデータが保持されない (ただし、 --persist).
  • --リモート: コードはローカルで実行されますが、バインディング (KV、D1、R2) これらは、Cloudflare アカウントからの実際のデータを指します。テストに役立ちます 実際のデータですが、インターネット接続が必要であり、クォータを消費します。

CI/CD: 自動テストのための GitHub アクション

# .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: 推奨されるテスト スクリプト

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

ワーカーテストにおける一般的なアンチパターン

避けるべき間違い

  • 待たないで waitOnExecutionContext(): I ctx.waitUntil() フェッチ ハンドラーが戻った後も続行されます。それなし await waitOnExecutionContext(ctx) テストでは、非同期操作 (KV への保存など) が完了しない場合があります 主張の前に。
  • Vitest の代わりに Jest を使用する: Jest はプーリングをネイティブにサポートしていません クラウドフレア。 Jest から移行している場合は、Vitest の互換モードを使用してください。
  • 変更可能なグローバル状態を使用してテストします。 同じ分離物であることを忘れないでください 複数のリクエストを処理できます。テストは独立している必要があります: を使用します。 beforeEach KV/D1 状態をリセットします。
  • テストでの実際の HTTP 呼び出し: アメリカ合衆国 fetchMock e fetchMock.disableNetConnect() テストが正しいことを確認するために 決定的であり、外部サービスに依存しません。

結論と次のステップ

ヴィテスト+付き @cloudflare/vitest-pool-workers、あなたは 実際のワーカー環境で実行されるワーカー向けのプロフェッショナルなテスト ワークフロー、 バインディング動作を検証するためにデプロイする必要がなくなりました。 CI/CD とシームレスに統合します。セットアップコストが低い、フィードバック 高速で、テストは実稼働環境に忠実です。

シリーズの次の記事

  • 第10条: エッジのフルスタック アーキテクチャ — ケーススタディ ゼロから実稼働まで: ワーカー、D1、R2、認証を備えた完全なアプリケーション JWT と自動化された CI/CD。