**Status**: ✅ Production Ready | Last Verified: 2025-01-27
Tests Cloudflare Workers using Vitest with real bindings (D1, KV, R2, DO, Queues, AI) in isolated Miniflare environments. Use when writing unit or integration tests for Worker functions that need to verify behavior with actual Cloudflare runtime and bindings.
/plugin marketplace add secondsky/claude-skills/plugin install workers-ci-cd@claude-skillsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
references/binding-mocks.mdreferences/coverage-optimization.mdreferences/integration-testing.mdreferences/troubleshooting.mdreferences/vitest-setup.mdscripts/run-tests.shscripts/setup-vitest.shtemplates/basic-test.tstemplates/binding-mock-test.tstemplates/vitest-config.tsStatus: ✅ Production Ready | Last Verified: 2025-01-27 Vitest: 2.1.8 | @cloudflare/vitest-pool-workers: 0.7.2 | Miniflare: Latest
Testing Cloudflare Workers with Vitest and @cloudflare/vitest-pool-workers enables writing unit and integration tests that run in a real Workers environment with full binding support (D1, KV, R2, Durable Objects, Queues, AI). Tests execute in Miniflare for local development and can run in CI/CD with actual Workers runtime behavior.
Key capabilities: Binding mocks, execution context testing, edge runtime simulation, coverage tracking, fast test execution.
@cloudflare/vitest-pool-workers 0.7.2 (January 2025):
cloudflare:test module for env/ctx accessMigration from older versions:
# Update dependencies
bun add -D vitest@^2.1.8 @cloudflare/vitest-pool-workers@^0.7.2
# Update vitest.config.ts (new pool configuration format)
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
miniflare: { compatibilityDate: '2025-01-27' }
}
}
}
});
bun add -D vitest @cloudflare/vitest-pool-workers
vitest.config.tsimport { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
miniflare: {
compatibilityDate: '2025-01-27',
compatibilityFlags: ['nodejs_compat']
}
}
}
}
});
import { describe, it, expect } from 'vitest';
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import worker from '../src/index';
describe('Worker', () => {
it('responds with 200', async () => {
const request = new Request('http://example.com/');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
});
});
bun test
# or
bunx vitest
cloudflare:test for Env Access✅ CORRECT:
import { env } from 'cloudflare:test';
it('queries D1', async () => {
const result = await env.DB.prepare('SELECT * FROM users').all();
expect(result.results).toHaveLength(0); // Fresh isolated DB per test
});
❌ WRONG:
// Don't manually create env object
const env = { DB: mockDB }; // ❌ Won't use real D1 binding
Why: cloudflare:test provides real bindings configured from wrangler.jsonc with isolated storage per test.
✅ CORRECT:
it('handles async operations', async () => {
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx); // ✅ Ensures ctx.waitUntil completes
expect(response.status).toBe(200);
});
❌ WRONG:
it('missing wait', async () => {
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
// ❌ Missing waitOnExecutionContext - ctx.waitUntil tasks may not complete
expect(response.status).toBe(200);
});
Why: Workers use ctx.waitUntil() for background tasks (logging, analytics). Without waiting, these tasks may not complete in tests.
✅ CORRECT:
describe('KV Operations', () => {
it('test 1: writes to KV', async () => {
await env.CACHE.put('key', 'value1');
const val = await env.CACHE.get('key');
expect(val).toBe('value1'); // ✅ Isolated
});
it('test 2: clean state', async () => {
const val = await env.CACHE.get('key');
expect(val).toBeNull(); // ✅ Test 1's data doesn't leak here
});
});
Why: Each test runs with fresh binding storage (automatic isolation).
✅ CORRECT:
// vitest.config.ts
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' } // ✅ Reads bindings from wrangler
}
}
}
});
❌ WRONG:
// vitest.config.ts
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
// ❌ No wrangler config - bindings won't be available
miniflare: { compatibilityDate: '2025-01-27' }
}
}
}
});
Why: Wrangler config defines all bindings (D1, KV, R2, etc.). Without it, env will be empty.
✅ CORRECT:
// vitest.config.ts
miniflare: {
compatibilityDate: '2025-01-27' // ✅ Matches wrangler.jsonc
}
// wrangler.jsonc
{
"compatibility_date": "2025-01-27"
}
Why: Ensures test environment matches production runtime behavior.
D1 Database:
import { env } from 'cloudflare:test';
it('queries D1', async () => {
// Insert test data
await env.DB.prepare('INSERT INTO users (name) VALUES (?)').bind('Alice').run();
// Query
const result = await env.DB.prepare('SELECT * FROM users WHERE name = ?').bind('Alice').first();
expect(result?.name).toBe('Alice');
});
KV Namespace:
it('reads from KV', async () => {
await env.CACHE.put('test-key', 'test-value');
const value = await env.CACHE.get('test-key');
expect(value).toBe('test-value');
});
R2 Bucket:
it('uploads to R2', async () => {
await env.BUCKET.put('file.txt', 'Hello World');
const object = await env.BUCKET.get('file.txt');
expect(await object?.text()).toBe('Hello World');
});
Durable Objects:
it('interacts with Durable Object', async () => {
const id = env.COUNTER.idFromName('test-counter');
const stub = env.COUNTER.get(id);
const response = await stub.fetch('http://fake/increment');
const data = await response.json();
expect(data.count).toBe(1);
});
Unit Test (single function):
import { validateInput } from '../src/utils/validator';
it('validates input', () => {
const result = validateInput({ name: 'Alice', age: 30 });
expect(result.valid).toBe(true);
});
Integration Test (full fetch handler):
import worker from '../src/index';
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
it('handles full request flow', async () => {
const request = new Request('http://example.com/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' })
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(201);
const user = await response.json();
expect(user.name).toBe('Alice');
});
Add to vitest.config.ts:
export default defineWorkersConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
});
Run with coverage:
bunx vitest run --coverage
it('creates user via API', async () => {
const request = new Request('http://example.com/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Bob', email: 'bob@example.com' })
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(201);
// Verify DB insert
const user = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind('bob@example.com')
.first();
expect(user?.name).toBe('Bob');
});
it('caches API responses', async () => {
// First request (cache miss)
const req1 = new Request('http://example.com/api/data');
const ctx1 = createExecutionContext();
const res1 = await worker.fetch(req1, env, ctx1);
await waitOnExecutionContext(ctx1);
expect(res1.headers.get('X-Cache')).toBe('MISS');
// Second request (cache hit)
const req2 = new Request('http://example.com/api/data');
const ctx2 = createExecutionContext();
const res2 = await worker.fetch(req2, env, ctx2);
await waitOnExecutionContext(ctx2);
expect(res2.headers.get('X-Cache')).toBe('HIT');
});
it('handles file upload', async () => {
const formData = new FormData();
formData.append('file', new Blob(['test content'], { type: 'text/plain' }), 'test.txt');
const request = new Request('http://example.com/upload', {
method: 'POST',
body: formData
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
// Verify R2 upload
const object = await env.BUCKET.get('test.txt');
expect(await object?.text()).toBe('test content');
});
it('maintains counter state', async () => {
const id = env.COUNTER.idFromName('my-counter');
const stub = env.COUNTER.get(id);
// Increment 3 times
for (let i = 0; i < 3; i++) {
await stub.fetch('http://fake/increment');
}
// Verify state
const response = await stub.fetch('http://fake/value');
const data = await response.json();
expect(data.count).toBe(3);
});
it('processes queue messages', async () => {
const messages = [
{ id: '1', body: { action: 'email', to: 'user@example.com' }, timestamp: new Date() }
];
// Simulate queue batch
await worker.queue(
{
queue: 'my-queue',
messages,
retryAll: () => {},
ackAll: () => {}
},
env
);
// Verify processing (check DB, logs, etc.)
const log = await env.DB.prepare('SELECT * FROM email_log WHERE id = ?').bind('1').first();
expect(log?.status).toBe('sent');
});
Use descriptive test names:
it('returns 404 when user not found', async () => {});
it('validates email format before saving', async () => {});
Test error cases:
it('returns 400 for invalid JSON', async () => {
const request = new Request('http://example.com/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'invalid json'
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(400);
});
Group related tests:
describe('User API', () => {
describe('POST /users', () => {
it('creates user with valid data', async () => {});
it('rejects duplicate email', async () => {});
it('validates required fields', async () => {});
});
});
Use beforeEach for setup:
describe('Database tests', () => {
beforeEach(async () => {
// Seed test data
await env.DB.prepare('INSERT INTO users (name) VALUES (?)').bind('Test User').run();
});
it('queries users', async () => {
const result = await env.DB.prepare('SELECT * FROM users').all();
expect(result.results).toHaveLength(1);
});
});
Test realistic scenarios:
it('handles concurrent requests', async () => {
const requests = Array(10).fill(null).map(() =>
worker.fetch(new Request('http://example.com/'), env, createExecutionContext())
);
const responses = await Promise.all(requests);
expect(responses.every(r => r.status === 200)).toBe(true);
});
Don't share state between tests:
// ❌ BAD: Leaky state
let counter = 0;
it('test 1', () => { counter++; });
it('test 2', () => { expect(counter).toBe(1); }); // Fragile!
// ✅ GOOD: Isolated
it('test 1', () => { const counter = 0; counter++; });
it('test 2', () => { const counter = 0; /* fresh state */ });
Don't forget to wait:
// ❌ BAD
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(200); // ctx.waitUntil not finished
// ✅ GOOD
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
Don't hardcode URLs:
// ❌ BAD
const request = new Request('http://example.com/test');
// ✅ GOOD
const request = new Request('http://fake-host/test'); // Host doesn't matter in tests
Don't test implementation details:
// ❌ BAD: Testing internals
expect(worker.privateHelperFunction).toBeDefined();
// ✅ GOOD: Testing behavior
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(200);
ReferenceError: env is not definedCause: Not importing env from cloudflare:test.
Fix:
import { env } from 'cloudflare:test'; // ✅ Add this import
Prevention: Always use cloudflare:test module for env access.
TypeError: Cannot read property 'DB' of undefinedCause: wrangler.jsonc not loaded in vitest.config.ts.
Fix:
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' } // ✅ Add this
}
}
}
});
Prevention: Always configure wrangler path in vitest config.
Error: D1_ERROR: no such table: usersCause: D1 database schema not applied in tests.
Fix:
// Option 1: Seed in beforeEach
beforeEach(async () => {
await env.DB.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)
`);
});
// Option 2: Use migrations (load from file)
beforeEach(async () => {
const schema = await fs.readFile('./migrations/schema.sql', 'utf-8');
await env.DB.exec(schema);
});
Prevention: Create schema before each test or use shared setup.
Error: ctx.waitUntil tasks did not completeCause: Missing await waitOnExecutionContext(ctx).
Fix:
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx); // ✅ Add this
Prevention: Always wait on execution context in tests.
Error: SELF is not definedCause: Using old SELF.fetch() pattern instead of direct worker import.
Fix:
// ❌ OLD (vitest-pool-workers <0.5)
import { SELF } from 'cloudflare:test';
await SELF.fetch(request);
// ✅ NEW (vitest-pool-workers ≥0.7)
import worker from '../src/index';
await worker.fetch(request, env, ctx);
Prevention: Use direct worker imports (modern pattern).
Error: KV.get() returned data from previous testCause: Believing storage is shared (it's not, but may indicate test leak).
Fix: Each test is isolated. If seeing this, check for:
// ❌ Test pollution (shared variable)
let cache = {};
it('test 1', () => { cache.key = 'value'; });
it('test 2', () => { expect(cache.key).toBeUndefined(); }); // Fails!
// ✅ Proper isolation
it('test 1', async () => { await env.CACHE.put('key', 'value1'); });
it('test 2', async () => { const val = await env.CACHE.get('key'); expect(val).toBeNull(); });
Prevention: Don't use shared variables for test data.
TypeError: env.BUCKET.put is not a functionCause: R2 binding not configured in wrangler.jsonc.
Fix:
// wrangler.jsonc
{
"r2_buckets": [
{ "binding": "BUCKET", "bucket_name": "test-bucket" }
]
}
Prevention: Define all bindings in wrangler config.
Error: Pool 'workers' is not supportedCause: Missing @cloudflare/vitest-pool-workers dependency.
Fix:
bun add -D @cloudflare/vitest-pool-workers
Prevention: Install pool package for Workers testing.
Load reference files for detailed, specialized content:
Load references/vitest-setup.md when:
Load references/binding-mocks.md when:
Load references/integration-testing.md when:
Load references/coverage-optimization.md when:
Load references/troubleshooting.md when:
Load templates/vitest-config.ts for:
Load templates/basic-test.ts for:
Load templates/binding-mock-test.ts for:
Load scripts/setup-vitest.sh for:
Load scripts/run-tests.sh for:
For service-specific testing patterns, load:
This skill focuses on cross-cutting Workers testing patterns applicable to ALL binding types and Workers features.
Questions? Load references/troubleshooting.md or use /workers-debug command for interactive help.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.