Appearance
A Comprehensive Guide to Testing Cloudflare Workers
Core Concepts & Pricing Context
Understanding the architecture and pricing of Cloudflare's services is crucial for writing effective tests.
Note: For comprehensive pricing details, architecture fundamentals, and cost optimization strategies, see Cloudflare Workers Pricing & Architecture Guide.
Durable Objects (DO)
Durable Objects provide strongly consistent storage and are ideal for stateful applications.
Key Characteristics
- Execution Model: Single-threaded event loop with RPC access from Workers
- Throughput: Maximum 1000 requests/second per DO
- Memory: Fixed 128MB RAM allocation (billed regardless of actual usage)
- Runtime: Maximum 30 seconds active runtime per request
- Location: Once created, stays in the region closest to initial request
- Eviction: Can be evicted immediately upon code deployment
- In-Memory State: Can store class members for fast reads, lost on eviction
Pricing Summary
- Requests: $0.15/million
- Duration: $12.50/million GB-seconds (active wall-clock time)
- A 24/7 active DO costs ~$4.15/month in duration charges
- WebSocket connections incur duration charges for entire connection time
- will be evicted after 10 seconds after last client disconnect
- Storage:
- Rows Scanned: $0.001/million
- Rows Written: $1.00/million (includes index writes,
setAlarm()) - Data Stored: $0.20/GB-month
Testing Implications
- Your testing strategy should be mindful of DO activation and duration
- Tests should accurately simulate production behavior including evictions
- Avoid unnecessary DO activations during test runs
IMPORTANT: We do not need to mock the DO in test cases, use the real DO as is from
env.
Workers KV
KV is a global, low-latency key-value store with eventual consistency.
Key Characteristics
- Consistency: Eventually consistent with 60-second TTL per datacenter
- Writes in one datacenter may take up to 60s to propagate globally
- Stale reads possible for up to 60 seconds
- Throughput: Virtually unlimited reads; writes limited to 1 write/second per key
- Not Suitable For: Frequently updating mutable states (e.g., every second)
Pricing Summary
- Writes/Deletes/Lists: $5.00/million
- Reads: $0.50/million
- Stored Data: $0.50/GB-month
Testing Implications
- Tests interacting with KV must account for potential data staleness
- Write operations should consider rate limits in integration tests
- Avoid testing patterns that require strong consistency
Containers
For more complex workloads, Containers offer a familiar environment within the Workers ecosystem.
Key Characteristics
- Execution: Requests proxied through Workers; each container has its own DO
- State: All disk is ephemeral (reset on restart)
- Lifecycle: No guaranteed uptime; host restarts occur irregularly
- Shutdown: 15-minute grace period between
SIGTERMandSIGKILL
Testing Implications
- Tests must validate behavior after fresh starts
- Cannot assume disk state persists between test runs
- Graceful shutdown logic needs verification
Key Testing Patterns & Best Practices
1. Test State, Not Implementation
The most critical principle for testing Durable Objects is to verify the outcome of an operation, not whether a specific method was called.
Problem: Spying on DO stub methods from a Worker test is not possible because the stub is a network proxy, not a direct object reference.
typescript// ❌ ANTI-PATTERN: This will not work. const spy = vi.spyOn(myDurableObjectStub, "someMethod");Solution: Run code directly within the Durable Object using
runInDurableObjectto inspect its internal state after an action has been performed.typescript// ✅ BEST PRACTICE: Assert on the resulting state change. await runInDurableObject(myDurableObjectStub, async (instance, state) => { await instance.performAction({ value: "new-state" }); }); await runInDurableObject(myDurableObjectStub, async (instance, state) => { const result = await state.storage.sql.exec("SELECT data FROM my_table WHERE id = 1").one(); expect(result?.data).toBe("new-state"); });
2. Handling Asynchronicity and Timing
Tests often fail intermittently due to race conditions. Do not rely on setTimeout as a primary strategy for synchronization.
Problem: Assertions run before an asynchronous operation (like a price update propagating or a state change committing) has completed.
Solution: Create deterministic tests. Manually trigger checks and introduce small, explicit delays only when necessary to ensure stability.
typescript// ❌ ANTI-PATTERN: Relying on a long, arbitrary wait. await instance.startBackgroundTask(); await new Promise((resolve) => setTimeout(resolve, 5000)); // Hope it's done? // Assertion might still fail. // ❌ Still not good: Manually trigger the check and wait briefly. await instance.setInitialState({ price: 100 }); await new Promise((resolve) => setTimeout(resolve, 200)); // Allow initial state to settle. // ✅ BEST PRACTICE: Manually trigger the check and wait for state changes await instance.processStateWithPrice({ triggeringPrice: 95 }); await new Promise((resolve) => setTimeout(resolve, 100)); // Allow processing to complete. const result = await vi.waitFor(() => instance.getState() === "good", { timeout: 5_000 }); // Now verify the outcome.
3. Test Data Isolation
Ensure tests are independent and can run in any order without side effects.
Problem: Tests share DO instances or data, causing one test's state to bleed into another's, leading to unpredictable failures.
Solution: Generate unique DO IDs for each test to avoid conflict between steps, unless we intentionally want the same DO.
typescriptimport { SELF } from "cloudflare:test"; describe("My Feature", () => { it("should do X", () => { const id = env.MY_DO.idFromName(`test-case-X-${crypto.randomUUID()}`); // ... }); it("should do Y", () => { const id = env.MY_DO.idFromName(`test-case-Y-${crypto.randomUUID()}`); // ... }); });
4. SQL Parameter Binding in Tests
typescript
// ❌
state.storage.sql.exec(`SELECT * FROM users WHERE id = ?`, [userId]);typescript
// ✅
state.storage.sql.exec(`SELECT * FROM users WHERE id = ?`, userId);5. SELF.fetch
If we want to test against the worker endpoint (not DO), we can use SELF.fetch()
typescript
const response = await SELF.fetch("http://example.com/some-path");
const result = await response.text(); // or await response.json()You MUST consume the body even if you don't care about the results, e.g.
typescript
const response = await SELF.fetch("http://example.com/do-something");
await response.text(); // Just consume itTest Structure and Workflow
Adopt a clear Setup -> Action -> Verification structure for all tests.
Test Structure Example
typescript
import { env, runInDurableObject, SELF } from "cloudflare:test";
import { describe, it, expect, beforeEach } from "vitest";
describe("User Session DO", () => {
const getPriceCollectorDO = () => {
const id = env.AWESOME_DO.idFromName(crypto.randomUUID());
return env.AWESOME_DO.get(id);
};
it("should log in a user and create a session record", async () => {
const userStub = getPriceCollectorDO();
// 1. ACTION
await runInDurableObject(priceFeedDO, async (instance) => {
const result = await instance.login("test@example.com", "password123");
expect(result.success).toBe(true);
});
// 2. VERIFICATION
await runInDurableObject(userStub, async (instance, state) => {
const session = state.storage.sql.exec("SELECT * FROM sessions WHERE user_email = 'test@example.com'").one();
expect(session).not.toBeNull();
expect(session?.isActive).toBe(1);
});
});
});Common Test Patterns
Setting up State: Use
runInDurableObjectto directly insert or modify data to establish a known starting state.typescriptawait runInDurableObject(stub, async (instance, state) => { // Set up a user and their initial data state.storage.sql.exec("INSERT INTO items (id, name) VALUES ('item-1', 'Test Item')"); });
Important Testing Limitations
1. Fake Timers Are Not Reliable with Durable Objects
Problem: Using vi.useFakeTimers() and vi.setSystemTime() with Cloudflare Durable Objects can cause storage isolation errors and unpredictable test failures.
typescript
// ❌ ANTI-PATTERN: This can cause storage isolation errors
vi.useFakeTimers();
vi.setSystemTime(baseTime);
await runInDurableObject(stub, async (instance) => {
// Test code...
});
vi.useRealTimers();Solution: Use real timestamps and create "stale" data by using past timestamps:
typescript
// ✅ BEST PRACTICE: Use real time with past timestamps
const staleTime = Date.now() - 120000; // 2 minutes ago
await runInDurableObject(stub, async (instance) => {
// Create stale state with past timestamp
const staleState = {
lastTimestamp: staleTime,
// ... other state
};
instance.writeKV("state", staleState);
});2. DO Eviction Cannot Be Simulated
Problem: The runInDurableObject() function reuses the same DO instance throughout a test, so you cannot truly simulate DO eviction and re-initialization.
typescript
// ❌ MISCONCEPTION: This does NOT create a new DO instance
await runInDurableObject(stub, async (instance) => {
// First "session"
});
// The DO is NOT evicted here
await runInDurableObject(stub, async (instance) => {
// Same instance, not a fresh one
});Solution: Manually simulate post-eviction state by writing stale data:
typescript
// ✅ BEST PRACTICE: Manually create stale state to simulate eviction
await runInDurableObject(stub, async (instance) => {
// Simulate what would be in storage after eviction
const postEvictionState = {
lastMonitoringHeartbeat: Date.now() - 300000, // 5 minutes ago
isMonitoring: false, // DO would not be monitoring after eviction
// ... other persistent state
};
instance.writeKV("monitoring-state", postEvictionState);
});3. Storage Operations Are Synchronous
Note: Unlike in production Cloudflare Workers, storage operations in Durable Objects are synchronous within the test environment.
typescript
// In tests, these complete immediately
state.storage.put("key", value); // No await needed
const result = state.storage.sql.exec("SELECT * FROM table"); // Synchronous4. Timestamp Precision
Important: Storage systems may quantize timestamps to second precision:
typescript
// ✅ BEST PRACTICE: Account for timestamp quantization
const timestamp = Date.now(); // e.g., 1700000001234
const storedTimestamp = Math.floor(timestamp / 1000) * 1000; // 1700000001000
expect(retrievedTimestamp).toBe(storedTimestamp); // NOT original timestamp5. Batch Flushing in Tests
When testing batched operations, be aware of automatic flushing behavior:
typescript
// ✅ BEST PRACTICE: Force flush for deterministic tests
await instance.onPriceUpdate({ timestamp: Date.now() });
// Force flush all batches to ensure data is persisted
(instance.priceStorageManager as any).flushOldBatches(Number.MAX_SAFE_INTEGER);
// Now data is guaranteed to be in storage