Loading...
Loading...
Comprehensive testing guide for Cloudflare Workers using Vitest and @cloudflare/vitest-pool-workers. Use for test setup, binding mocks (D1/KV/R2/DO), integration tests, or encountering test failures, mock errors, coverage issues.
npx skill4agent add secondsky/claude-skills cloudflare-workers-testingcloudflare:test# 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-workersvitest.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 vitestcloudflare:testimport { 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
});// Don't manually create env object
const env = { DB: mockDB }; // ❌ Won't use real D1 bindingcloudflare:testwrangler.jsoncit('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);
});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);
});ctx.waitUntil()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
});
});// vitest.config.ts
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' } // ✅ Reads bindings from wrangler
}
}
}
});// vitest.config.ts
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
// ❌ No wrangler config - bindings won't be available
miniflare: { compatibilityDate: '2025-01-27' }
}
}
}
});env// vitest.config.ts
miniflare: {
compatibilityDate: '2025-01-27' // ✅ Matches wrangler.jsonc
}
// wrangler.jsonc
{
"compatibility_date": "2025-01-27"
}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');
});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');
});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');
});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);
});import { validateInput } from '../src/utils/validator';
it('validates input', () => {
const result = validateInput({ name: 'Alice', age: 30 });
expect(result.valid).toBe(true);
});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');
});vitest.config.tsexport 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
}
}
}
});bunx vitest run --coverageit('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');
});it('returns 404 when user not found', async () => {});
it('validates email format before saving', async () => {});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);
});describe('User API', () => {
describe('POST /users', () => {
it('creates user with valid data', async () => {});
it('rejects duplicate email', async () => {});
it('validates required fields', async () => {});
});
});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);
});
});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);
});// ❌ 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 */ });// ❌ 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);// ❌ BAD
const request = new Request('http://example.com/test');
// ✅ GOOD
const request = new Request('http://fake-host/test'); // Host doesn't matter in tests// ❌ 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 definedenvcloudflare:testimport { env } from 'cloudflare:test'; // ✅ Add this importcloudflare:testTypeError: Cannot read property 'DB' of undefinedwrangler.jsoncvitest.config.tsexport default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' } // ✅ Add this
}
}
}
});Error: D1_ERROR: no such table: users// 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);
});Error: ctx.waitUntil tasks did not completeawait waitOnExecutionContext(ctx)const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx); // ✅ Add thisError: SELF is not definedSELF.fetch()// ❌ 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);Error: KV.get() returned data from previous test// ❌ 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(); });TypeError: env.BUCKET.put is not a functionwrangler.jsonc// wrangler.jsonc
{
"r2_buckets": [
{ "binding": "BUCKET", "bucket_name": "test-bucket" }
]
}Error: Pool 'workers' is not supported@cloudflare/vitest-pool-workersbun add -D @cloudflare/vitest-pool-workersreferences/vitest-setup.mdreferences/binding-mocks.mdreferences/integration-testing.mdreferences/coverage-optimization.mdreferences/troubleshooting.mdtemplates/vitest-config.tstemplates/basic-test.tstemplates/binding-mock-test.tsscripts/setup-vitest.shscripts/run-tests.shreferences/troubleshooting.md/workers-debug