Skip to content

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 SIGTERM and SIGKILL

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 runInDurableObject to 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.

    typescript
    import { 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 it

Test 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 runInDurableObject to directly insert or modify data to establish a known starting state.

    typescript
    await 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"); // Synchronous

4. 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 timestamp

5. 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