로컬 작업자 테스트: Miniflare, Vitest 및 Wrangler Dev
Miniflare 및 Vitest를 사용하여 로컬에서 작업자를 테스트하기 위한 전체 워크플로: 핸들러 유닛 테스트, 시뮬레이션된 바인딩을 사용한 통합 테스트 및 디버깅 Cloudflare에 배포하지 않고 Wrangler 개발을 사용합니다.
엣지에서의 테스트 문제
Cloudflare Worker 테스트는 기존 Node.js 테스트와 비교할 때 고유한 과제를 제시합니다.
Workers 런타임은 Node.js가 아닙니다. 웹 플랫폼 API의 하위 집합을 노출하지만 그렇지 않습니다.
파일 시스템 액세스, 사용하지 않음 require() 실행 모델이 다릅니다.
이는 Jest와 같은 표준 실행기가 해당 환경에서 테스트된다는 것을 의미합니다. jsdom
o 마디 실제 실행 환경을 올바르게 시뮬레이션하지 않습니다.
Cloudflare 생태계 솔루션은 다음과 같습니다. 미니플레어, 시뮬레이터 Workers 런타임의 로컬 기반 일한. Miniflare도 동일하게 표시됩니다. 프로덕션에서 찾을 수 있는 API(KV, R2, D1, 내구성 개체, 캐시 API, 바인딩) Cloudflare에 연결하지 않고도 로컬 환경에서 사용할 수 있습니다.
무엇을 배울 것인가
- Vitest + Miniflare를 사용한 프로젝트 설정(wrangler.toml 구성)
- 시뮬레이션된 요청을 사용한 가져오기 핸들러의 단위 테스트
- 인메모리 저장소로 KV, R2 및 D1 테스트
- 시뮬레이션 세션을 통한 지속성 개체 테스트
- 외부 서비스로부터 테스트를 격리하기 위한 모의 fetch()
- 검사기를 사용한 대화형 개발 및 디버깅을 위한 Wrangler 개발
- 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 at the Edge)
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 다음을 수행할 수 있는 현재 작업자에 대한 참조입니다.
외부 클라이언트로서의 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 Dev: 대화형 개발
wrangler dev 작업자 런타임을 시뮬레이션하는 로컬 서버를 시작합니다.
모든 바인딩을 포함하여 완료됩니다. 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의 차이점
- --local (기본값): 모든 바인딩은 메모리 내/로컬입니다.
빠르고 비용이 들지 않으며 오프라인으로 작업할 수 있습니다. 다시 시작해도 데이터가 유지되지 않습니다.
(만약
--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의 호환 모드를 사용하세요.
-
변경 가능한 전역 상태로 테스트합니다. 동일한 격리를 기억하십시오.
여러 요청을 처리할 수 있습니다. 테스트는 독립적이어야 합니다.
beforeEachKV/D1 상태를 재설정합니다. -
테스트의 실제 HTTP 호출: 미국
fetchMockefetchMock.disableNetConnect()테스트가 제대로 이루어졌는지 확인하기 위해 결정적이며 외부 서비스에 의존하지 않습니다.
결론 및 다음 단계
비테스트+와 함께 @cloudflare/vitest-pool-workers, 당신은
실제 작업자 환경에서 실행되는 작업자를 위한 전문적인 테스트 워크플로,
바인딩 동작을 확인하기 위해 배포할 필요가 없습니다.
CI/CD와 원활하게 통합됩니다. 설치 비용이 저렴하고 피드백이
속도가 빠르고 테스트가 프로덕션 환경에 충실합니다.
시리즈의 다음 기사
- 제10조: 엣지의 풀스택 아키텍처 — 사례 연구 Zero to Production: 작업자, D1, R2, 인증을 갖춘 완벽한 애플리케이션 JWT 및 자동화된 CI/CD.







