< blog

Testing Durable Objects for real

2026-03-26 · ZERO

Cloudflare has great docs on building with Durable Objects. The testing story gets less attention, but it's surprisingly good. @cloudflare/vitest-pool-workers runs your tests inside the actual Workers runtime: real DOs, real SQLite storage, real alarms. No mocking the platform.

Unit tests vitest (Node pool) *.unit.test.ts mock APIs pure logic fast, no runtime Integration tests vitest (Workers pool) DO/**/*.test.ts real DO real SQL alarms real workerd runtime

Two configs, two worlds

The trick is splitting tests into two vitest configs. Unit tests run in the normal Node pool. They're fast. You mock external APIs (Tavily, Gemini, Resend) and test pure logic like parsing, validation, and query building.

Integration tests use @cloudflare/vitest-pool-workers. These run inside workerd, the actual Workers runtime. Your Durable Objects get real embedded SQLite, real storage.setAlarm(), real WebSocketPair. The test file imports env from cloudflare:test and gets the same bindings your wrangler config declares.

What a real test looks like

Here's a simplified test from web-explorer. It creates a Durable Object, starts it, triggers an alarm, and checks that a card was generated:

import { env, runDurableObjectAlarm } from "cloudflare:test"; const id = env.EXPLORATION_DO.newUniqueId(); const stub = env.EXPLORATION_DO.get(id); // Start the exploration (arms the first alarm) await stub.start("2026-03-26"); // Fire the alarm — runs one step await runDurableObjectAlarm(stub); const data = await stub.getExploration(); expect(data.cards).toHaveLength(1); expect(data.status).toBe("generating");

That's a real Durable Object writing to real SQLite storage. The alarm fires one step of the exploration loop, stores the result, and the test reads it back. External APIs (LLM, search) are mocked with vi.mock() so no keys are needed. The platform itself is real.

The gotcha

vitest-pool-workers has an isolatedStorage option that resets DO storage between tests. Sounds great, but it conflicts with storage.setAlarm(). The alarm write happens inside the DO, and the isolation mechanism doesn't expect it.

The fix: set isolatedStorage: false and use unique DO IDs per test. Each test calls env.MY_DO.newUniqueId() to get a fresh instance. No shared state, no conflicts.

The split in practice

Across two projects (web-explorer and inboxkit), we have 150+ tests. Unit tests cover API clients, parsing, validation, crypto. Integration tests cover DO lifecycle: creation, alarm progression, WebSocket connections, RPC calls, error recovery. CI runs both in sequence. The whole suite finishes in under 10 seconds.


The setup takes 20 lines of config. After that, testing Durable Objects feels like testing any other code.

web-explorer (104 tests)