Testing Durable Objects
Write tests for Durable Objects using the Workers Vitest integration.
Use the @cloudflare/vitest-pool-workers ↗ package to write tests for your Durable Objects. This integration runs your tests inside the Workers runtime, giving you direct access to Durable Object bindings and APIs.
Install Vitest and the Workers Vitest integration as dev dependencies:
npm i -D vitest@~3.2.0 @cloudflare/vitest-pool-workerspnpm add -D vitest@~3.2.0 @cloudflare/vitest-pool-workersyarn add -D vitest@~3.2.0 @cloudflare/vitest-pool-workersThis example tests a simple counter Durable Object with SQLite storage:
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject { constructor(ctx, env) { super(ctx, env);
ctx.blockConcurrencyWhile(async () => { this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS counters ( name TEXT PRIMARY KEY, value INTEGER NOT NULL DEFAULT 0 ) `); }); }
async increment(name = "default") { this.ctx.storage.sql.exec( `INSERT INTO counters (name, value) VALUES (?, 1) ON CONFLICT(name) DO UPDATE SET value = value + 1`, name, ); const result = this.ctx.storage.sql .exec("SELECT value FROM counters WHERE name = ?", name) .one(); return result.value; }
async getCount(name = "default") { const result = this.ctx.storage.sql .exec("SELECT value FROM counters WHERE name = ?", name) .toArray(); return result[0]?.value ?? 0; }
async reset(name = "default") { this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name); }}
export default { async fetch(request, env) { const url = new URL(request.url); const counterId = url.searchParams.get("id") ?? "default";
const id = env.COUNTER.idFromName(counterId); const stub = env.COUNTER.get(id);
if (request.method === "POST") { const count = await stub.increment(); return Response.json({ count }); }
const count = await stub.getCount(); return Response.json({ count }); },};import { DurableObject } from "cloudflare:workers";
export interface Env { COUNTER: DurableObjectNamespace<Counter>;}
export class Counter extends DurableObject<Env> { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env);
ctx.blockConcurrencyWhile(async () => { this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS counters ( name TEXT PRIMARY KEY, value INTEGER NOT NULL DEFAULT 0 ) `); }); }
async increment(name: string = "default"): Promise<number> { this.ctx.storage.sql.exec( `INSERT INTO counters (name, value) VALUES (?, 1) ON CONFLICT(name) DO UPDATE SET value = value + 1`, name ); const result = this.ctx.storage.sql .exec<{ value: number }>("SELECT value FROM counters WHERE name = ?", name) .one(); return result.value; }
async getCount(name: string = "default"): Promise<number> { const result = this.ctx.storage.sql .exec<{ value: number }>("SELECT value FROM counters WHERE name = ?", name) .toArray(); return result[0]?.value ?? 0; }
async reset(name: string = "default"): Promise<void> { this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name); }}
export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const counterId = url.searchParams.get("id") ?? "default";
const id = env.COUNTER.idFromName(counterId); const stub = env.COUNTER.get(id);
if (request.method === "POST") { const count = await stub.increment(); return Response.json({ count }); }
const count = await stub.getCount(); return Response.json({ count }); },};Create a vitest.config.ts file that uses defineWorkersConfig:
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({ test: { poolOptions: { workers: { wrangler: { configPath: "./wrangler.toml" }, }, }, },});Make sure your Wrangler configuration includes the Durable Object binding and SQLite migration:
{ "name": "counter-worker", "main": "src/index.ts", "compatibility_date": "2024-12-01", "durable_objects": { "bindings": [ { "name": "COUNTER", "class_name": "Counter" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["Counter"] } ]}name = "counter-worker"main = "src/index.ts"compatibility_date = "2024-12-01"
[[durable_objects.bindings]]name = "COUNTER"class_name = "Counter"
[[migrations]]tag = "v1"new_sqlite_classes = [ "Counter" ]Create a test/tsconfig.json to configure TypeScript for your tests:
{ "extends": "../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", "types": ["@cloudflare/vitest-pool-workers"] }, "include": ["./**/*.ts", "../src/worker-configuration.d.ts"]}Create an env.d.ts file to type the test environment:
declare module "cloudflare:test" { interface ProvidedEnv extends Env {}}You can get a stub to a Durable Object directly from the env object provided by cloudflare:test:
import { env } from "cloudflare:test";import { describe, it, expect, beforeEach } from "vitest";
describe("Counter Durable Object", () => { // Each test gets isolated storage automatically it("should increment the counter", async () => { const id = env.COUNTER.idFromName("test-counter"); const stub = env.COUNTER.get(id);
// Call RPC methods directly on the stub const count1 = await stub.increment(); expect(count1).toBe(1);
const count2 = await stub.increment(); expect(count2).toBe(2);
const count3 = await stub.increment(); expect(count3).toBe(3); });
it("should track separate counters independently", async () => { const id = env.COUNTER.idFromName("test-counter"); const stub = env.COUNTER.get(id);
await stub.increment("counter-a"); await stub.increment("counter-a"); await stub.increment("counter-b");
expect(await stub.getCount("counter-a")).toBe(2); expect(await stub.getCount("counter-b")).toBe(1); expect(await stub.getCount("counter-c")).toBe(0); });
it("should reset a counter", async () => { const id = env.COUNTER.idFromName("test-counter"); const stub = env.COUNTER.get(id);
await stub.increment("my-counter"); await stub.increment("my-counter"); expect(await stub.getCount("my-counter")).toBe(2);
await stub.reset("my-counter"); expect(await stub.getCount("my-counter")).toBe(0); });
it("should isolate different Durable Object instances", async () => { const id1 = env.COUNTER.idFromName("counter-1"); const id2 = env.COUNTER.idFromName("counter-2");
const stub1 = env.COUNTER.get(id1); const stub2 = env.COUNTER.get(id2);
await stub1.increment(); await stub1.increment(); await stub2.increment();
// Each Durable Object instance has its own storage expect(await stub1.getCount()).toBe(2); expect(await stub2.getCount()).toBe(1); });});import { env } from "cloudflare:test";import { describe, it, expect, beforeEach } from "vitest";
describe("Counter Durable Object", () => { // Each test gets isolated storage automatically it("should increment the counter", async () => { const id = env.COUNTER.idFromName("test-counter"); const stub = env.COUNTER.get(id);
// Call RPC methods directly on the stub const count1 = await stub.increment(); expect(count1).toBe(1);
const count2 = await stub.increment(); expect(count2).toBe(2);
const count3 = await stub.increment(); expect(count3).toBe(3); });
it("should track separate counters independently", async () => { const id = env.COUNTER.idFromName("test-counter"); const stub = env.COUNTER.get(id);
await stub.increment("counter-a"); await stub.increment("counter-a"); await stub.increment("counter-b");
expect(await stub.getCount("counter-a")).toBe(2); expect(await stub.getCount("counter-b")).toBe(1); expect(await stub.getCount("counter-c")).toBe(0); });
it("should reset a counter", async () => { const id = env.COUNTER.idFromName("test-counter"); const stub = env.COUNTER.get(id);
await stub.increment("my-counter"); await stub.increment("my-counter"); expect(await stub.getCount("my-counter")).toBe(2);
await stub.reset("my-counter"); expect(await stub.getCount("my-counter")).toBe(0); });
it("should isolate different Durable Object instances", async () => { const id1 = env.COUNTER.idFromName("counter-1"); const id2 = env.COUNTER.idFromName("counter-2");
const stub1 = env.COUNTER.get(id1); const stub2 = env.COUNTER.get(id2);
await stub1.increment(); await stub1.increment(); await stub2.increment();
// Each Durable Object instance has its own storage expect(await stub1.getCount()).toBe(2); expect(await stub2.getCount()).toBe(1); });});Use the SELF fetcher to test your Worker's HTTP handler, which routes requests to Durable Objects:
import { SELF } from "cloudflare:test";import { describe, it, expect } from "vitest";
describe("Counter Worker integration", () => { it("should increment via HTTP POST", async () => { const response = await SELF.fetch("http://example.com?id=http-test", { method: "POST", });
expect(response.status).toBe(200); const data = await response.json(); expect(data.count).toBe(1); });
it("should get count via HTTP GET", async () => { // First increment the counter await SELF.fetch("http://example.com?id=get-test", { method: "POST" }); await SELF.fetch("http://example.com?id=get-test", { method: "POST" });
// Then get the count const response = await SELF.fetch("http://example.com?id=get-test"); const data = await response.json(); expect(data.count).toBe(2); });
it("should use different counters for different IDs", async () => { await SELF.fetch("http://example.com?id=counter-a", { method: "POST" }); await SELF.fetch("http://example.com?id=counter-a", { method: "POST" }); await SELF.fetch("http://example.com?id=counter-b", { method: "POST" });
const responseA = await SELF.fetch("http://example.com?id=counter-a"); const responseB = await SELF.fetch("http://example.com?id=counter-b");
const dataA = await responseA.json(); const dataB = await responseB.json();
expect(dataA.count).toBe(2); expect(dataB.count).toBe(1); });});import { SELF } from "cloudflare:test";import { describe, it, expect } from "vitest";
describe("Counter Worker integration", () => { it("should increment via HTTP POST", async () => { const response = await SELF.fetch("http://example.com?id=http-test", { method: "POST", });
expect(response.status).toBe(200); const data = await response.json<{ count: number }>(); expect(data.count).toBe(1); });
it("should get count via HTTP GET", async () => { // First increment the counter await SELF.fetch("http://example.com?id=get-test", { method: "POST" }); await SELF.fetch("http://example.com?id=get-test", { method: "POST" });
// Then get the count const response = await SELF.fetch("http://example.com?id=get-test"); const data = await response.json<{ count: number }>(); expect(data.count).toBe(2); });
it("should use different counters for different IDs", async () => { await SELF.fetch("http://example.com?id=counter-a", { method: "POST" }); await SELF.fetch("http://example.com?id=counter-a", { method: "POST" }); await SELF.fetch("http://example.com?id=counter-b", { method: "POST" });
const responseA = await SELF.fetch("http://example.com?id=counter-a"); const responseB = await SELF.fetch("http://example.com?id=counter-b");
const dataA = await responseA.json<{ count: number }>(); const dataB = await responseB.json<{ count: number }>();
expect(dataA.count).toBe(2); expect(dataB.count).toBe(1); });});Use runInDurableObject() to access instance properties and storage directly. This is useful for verifying internal state or testing private methods:
import { env, runInDurableObject, listDurableObjectIds } from "cloudflare:test";import { describe, it, expect } from "vitest";import { Counter } from "../src";
describe("Direct Durable Object access", () => { it("can access instance internals and storage", async () => { const id = env.COUNTER.idFromName("direct-test"); const stub = env.COUNTER.get(id);
// First, interact normally via RPC await stub.increment(); await stub.increment();
// Then use runInDurableObject to inspect internals await runInDurableObject(stub, async (instance, state) => { // Access the exact same class instance expect(instance).toBeInstanceOf(Counter);
// Access storage directly for verification const result = state.storage.sql .exec("SELECT value FROM counters WHERE name = ?", "default") .one(); expect(result.value).toBe(2); }); });
it("can list all Durable Object IDs in a namespace", async () => { // Create some Durable Objects const id1 = env.COUNTER.idFromName("list-test-1"); const id2 = env.COUNTER.idFromName("list-test-2");
await env.COUNTER.get(id1).increment(); await env.COUNTER.get(id2).increment();
// List all IDs in the namespace const ids = await listDurableObjectIds(env.COUNTER); expect(ids.length).toBe(2); expect(ids.some((id) => id.equals(id1))).toBe(true); expect(ids.some((id) => id.equals(id2))).toBe(true); });});import { env, runInDurableObject, listDurableObjectIds,} from "cloudflare:test";import { describe, it, expect } from "vitest";import { Counter } from "../src";
describe("Direct Durable Object access", () => { it("can access instance internals and storage", async () => { const id = env.COUNTER.idFromName("direct-test"); const stub = env.COUNTER.get(id);
// First, interact normally via RPC await stub.increment(); await stub.increment();
// Then use runInDurableObject to inspect internals await runInDurableObject(stub, async (instance: Counter, state) => { // Access the exact same class instance expect(instance).toBeInstanceOf(Counter);
// Access storage directly for verification const result = state.storage.sql .exec<{ value: number }>( "SELECT value FROM counters WHERE name = ?", "default" ) .one(); expect(result.value).toBe(2); }); });
it("can list all Durable Object IDs in a namespace", async () => { // Create some Durable Objects const id1 = env.COUNTER.idFromName("list-test-1"); const id2 = env.COUNTER.idFromName("list-test-2");
await env.COUNTER.get(id1).increment(); await env.COUNTER.get(id2).increment();
// List all IDs in the namespace const ids = await listDurableObjectIds(env.COUNTER); expect(ids.length).toBe(2); expect(ids.some((id) => id.equals(id1))).toBe(true); expect(ids.some((id) => id.equals(id2))).toBe(true); });});Each test automatically gets isolated storage. Durable Objects created in one test do not affect other tests:
import { env, listDurableObjectIds } from "cloudflare:test";import { describe, it, expect } from "vitest";
describe("Test isolation", () => { it("first test: creates a Durable Object", async () => { const id = env.COUNTER.idFromName("isolated-counter"); const stub = env.COUNTER.get(id);
await stub.increment(); await stub.increment(); expect(await stub.getCount()).toBe(2); });
it("second test: previous Durable Object does not exist", async () => { // The Durable Object from the previous test is automatically cleaned up const ids = await listDurableObjectIds(env.COUNTER); expect(ids.length).toBe(0);
// Creating the same ID gives a fresh instance const id = env.COUNTER.idFromName("isolated-counter"); const stub = env.COUNTER.get(id); expect(await stub.getCount()).toBe(0); });});import { env, listDurableObjectIds } from "cloudflare:test";import { describe, it, expect } from "vitest";
describe("Test isolation", () => { it("first test: creates a Durable Object", async () => { const id = env.COUNTER.idFromName("isolated-counter"); const stub = env.COUNTER.get(id);
await stub.increment(); await stub.increment(); expect(await stub.getCount()).toBe(2); });
it("second test: previous Durable Object does not exist", async () => { // The Durable Object from the previous test is automatically cleaned up const ids = await listDurableObjectIds(env.COUNTER); expect(ids.length).toBe(0);
// Creating the same ID gives a fresh instance const id = env.COUNTER.idFromName("isolated-counter"); const stub = env.COUNTER.get(id); expect(await stub.getCount()).toBe(0); });});SQLite-backed Durable Objects work seamlessly in tests. The SQL API is available when your Durable Object class is configured with new_sqlite_classes in your Wrangler configuration:
import { env, runInDurableObject } from "cloudflare:test";import { describe, it, expect } from "vitest";
describe("SQLite in Durable Objects", () => { it("can query and verify SQLite storage", async () => { const id = env.COUNTER.idFromName("sqlite-test"); const stub = env.COUNTER.get(id);
// Increment the counter a few times via RPC await stub.increment("page-views"); await stub.increment("page-views"); await stub.increment("api-calls");
// Verify the data directly in SQLite await runInDurableObject(stub, async (instance, state) => { // Query the database directly const rows = state.storage.sql .exec("SELECT name, value FROM counters ORDER BY name") .toArray();
expect(rows).toEqual([ { name: "api-calls", value: 1 }, { name: "page-views", value: 2 }, ]);
// Check database size is non-zero expect(state.storage.sql.databaseSize).toBeGreaterThan(0); }); });});import { env, runInDurableObject } from "cloudflare:test";import { describe, it, expect } from "vitest";
describe("SQLite in Durable Objects", () => { it("can query and verify SQLite storage", async () => { const id = env.COUNTER.idFromName("sqlite-test"); const stub = env.COUNTER.get(id);
// Increment the counter a few times via RPC await stub.increment("page-views"); await stub.increment("page-views"); await stub.increment("api-calls");
// Verify the data directly in SQLite await runInDurableObject(stub, async (instance, state) => { // Query the database directly const rows = state.storage.sql .exec<{ name: string; value: number }>("SELECT name, value FROM counters ORDER BY name") .toArray();
expect(rows).toEqual([ { name: "api-calls", value: 1 }, { name: "page-views", value: 2 }, ]);
// Check database size is non-zero expect(state.storage.sql.databaseSize).toBeGreaterThan(0); }); });});Use runDurableObjectAlarm() to immediately trigger a scheduled alarm without waiting for the timer. This allows you to test alarm handlers synchronously:
import { env, runInDurableObject, runDurableObjectAlarm,} from "cloudflare:test";import { describe, it, expect } from "vitest";import { Counter } from "../src";
describe("Durable Object alarms", () => { it("can trigger alarms immediately", async () => { const id = env.COUNTER.idFromName("alarm-test"); const stub = env.COUNTER.get(id);
// Increment counter and schedule a reset alarm await stub.increment(); await stub.increment(); expect(await stub.getCount()).toBe(2);
// Schedule an alarm (in a real app, this might be hours in the future) await runInDurableObject(stub, async (instance, state) => { await state.storage.setAlarm(Date.now() + 60_000); // 1 minute from now });
// Immediately execute the alarm without waiting const alarmRan = await runDurableObjectAlarm(stub); expect(alarmRan).toBe(true); // Alarm was scheduled and executed
// Verify the alarm handler ran (assuming it resets the counter) // Note: You'll need an alarm() method in your Durable Object that handles resets // expect(await stub.getCount()).toBe(0);
// Trying to run the alarm again returns false (no alarm scheduled) const alarmRanAgain = await runDurableObjectAlarm(stub); expect(alarmRanAgain).toBe(false); });});import { env, runInDurableObject, runDurableObjectAlarm,} from "cloudflare:test";import { describe, it, expect } from "vitest";import { Counter } from "../src";
describe("Durable Object alarms", () => { it("can trigger alarms immediately", async () => { const id = env.COUNTER.idFromName("alarm-test"); const stub = env.COUNTER.get(id);
// Increment counter and schedule a reset alarm await stub.increment(); await stub.increment(); expect(await stub.getCount()).toBe(2);
// Schedule an alarm (in a real app, this might be hours in the future) await runInDurableObject(stub, async (instance, state) => { await state.storage.setAlarm(Date.now() + 60_000); // 1 minute from now });
// Immediately execute the alarm without waiting const alarmRan = await runDurableObjectAlarm(stub); expect(alarmRan).toBe(true); // Alarm was scheduled and executed
// Verify the alarm handler ran (assuming it resets the counter) // Note: You'll need an alarm() method in your Durable Object that handles resets // expect(await stub.getCount()).toBe(0);
// Trying to run the alarm again returns false (no alarm scheduled) const alarmRanAgain = await runDurableObjectAlarm(stub); expect(alarmRanAgain).toBe(false); });});To test alarms, add an alarm() method to your Durable Object:
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject { // ... other methods ...
async alarm() { // This method is called when the alarm fires // Reset all counters this.ctx.storage.sql.exec("DELETE FROM counters"); }
async scheduleReset(afterMs) { await this.ctx.storage.setAlarm(Date.now() + afterMs); }}import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject { // ... other methods ...
async alarm() { // This method is called when the alarm fires // Reset all counters this.ctx.storage.sql.exec("DELETE FROM counters"); }
async scheduleReset(afterMs: number) { await this.ctx.storage.setAlarm(Date.now() + afterMs); }}Run your tests with:
npx vitestOr add a script to your package.json:
{ "scripts": { "test": "vitest" }}- Workers Vitest integration - Full documentation for the Vitest integration
- Durable Objects testing recipe ↗ - Example from the Workers SDK
- RPC testing recipe ↗ - Testing JSRPC with Durable Objects
Was this helpful?
- Resources
- API
- New to Cloudflare?
- Directory
- Sponsorships
- Open Source
- Support
- Help Center
- System Status
- Compliance
- GDPR
- Company
- cloudflare.com
- Our team
- Careers
- © 2025 Cloudflare, Inc.
- Privacy Policy
- Terms of Use
- Report Security Issues
- Trademark
-