@cloudflare/vitest-plugin ↗ パッケージを使い、Durable Objects のテストを書けます。この連携は Workers ランタイム内でテストを実行するため、Durable Object のバインディングと API に直接アクセスできます。
Vitest と Workers Vitest 連携を開発依存関係としてインストールします。
npm i -D vitest@^4.1.0 @cloudflare/vitest-pluginpnpm add -D vitest@^4.1.0 @cloudflare/vitest-pluginyarn add -D vitest@^4.1.0 @cloudflare/vitest-pluginこの例では、SQLite ストレージを使う簡単なカウンター Durable Object をテストします。
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
)
`);
});
}
// In-memory only. This field lives on the instance and is not persisted
// to storage, so it is reset whenever the Durable Object is evicted and
// reconstructed.
cachedHits = 0;
recordHit() {
return ++this.cachedHits;
}
getHits() {
return this.cachedHits;
}
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
)
`);
});
}
// In-memory only. This field lives on the instance and is not persisted
// to storage, so it is reset whenever the Durable Object is evicted and
// reconstructed.
cachedHits = 0;
recordHit(): number {
return ++this.cachedHits;
}
getHits(): number {
return this.cachedHits;
}
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 });
},
};cloudflareTest() プラグインを使う vitest.config.ts ファイルを作成します。
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: "./wrangler.jsonc" },
}),
],
});Wrangler の設定に、Durable Object バインディングと SQLite マイグレーションが含まれていることを確認します。
{
"name": "counter-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"durable_objects": {
"bindings": [
{ "name": "COUNTER", "class_name": "Counter" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] }
]
}name = "counter-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "Counter" ]テスト向けに TypeScript を設定する test/tsconfig.json を作成します。
{
"extends": "../tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": ["@cloudflare/vitest-plugin/types"]
},
"include": ["./**/*.ts", "../src/worker-configuration.d.ts"]
}テスト環境に型を付ける env.d.ts ファイルを作成します。
declare module "cloudflare:workers" {
interface ProvidedEnv extends Env {}
}cloudflare:workers が提供する env オブジェクトから、Durable Object のスタブを直接取得できます。
import { env } from "cloudflare:workers";
import { describe, it, expect, beforeEach } from "vitest";
describe("Counter Durable Object", () => {
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 persist storage within a test file", async () => {
const id = env.COUNTER.idFromName("test-counter");
const stub = env.COUNTER.get(id);
expect(await stub.getCount()).toBe(3);
});
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:workers";
import { describe, it, expect, beforeEach } from "vitest";
describe("Counter Durable Object", () => {
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 persist storage within a test file", async () => {
const id = env.COUNTER.idFromName("test-counter");
const stub = env.COUNTER.get(id);
expect(await stub.getCount()).toBe(3);
});
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);
});
});exports.default.fetch() を使い、Durable Objects へリクエストをルーティングする Worker の HTTP ハンドラーをテストします。
import { exports } from "cloudflare:workers";
import { describe, it, expect } from "vitest";
describe("Counter Worker integration", () => {
it("should increment via HTTP POST", async () => {
const response = await exports.default.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 exports.default.fetch("http://example.com?id=get-test", {
method: "POST",
});
await exports.default.fetch("http://example.com?id=get-test", {
method: "POST",
});
// Then get the count
const response = await exports.default.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 exports.default.fetch("http://example.com?id=counter-a", {
method: "POST",
});
await exports.default.fetch("http://example.com?id=counter-a", {
method: "POST",
});
await exports.default.fetch("http://example.com?id=counter-b", {
method: "POST",
});
const responseA = await exports.default.fetch(
"http://example.com?id=counter-a",
);
const responseB = await exports.default.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 { exports } from "cloudflare:workers";
import { describe, it, expect } from "vitest";
describe("Counter Worker integration", () => {
it("should increment via HTTP POST", async () => {
const response = await exports.default.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 exports.default.fetch("http://example.com?id=get-test", { method: "POST" });
await exports.default.fetch("http://example.com?id=get-test", { method: "POST" });
// Then get the count
const response = await exports.default.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 exports.default.fetch("http://example.com?id=counter-a", { method: "POST" });
await exports.default.fetch("http://example.com?id=counter-a", { method: "POST" });
await exports.default.fetch("http://example.com?id=counter-b", { method: "POST" });
const responseA = await exports.default.fetch("http://example.com?id=counter-a");
const responseB = await exports.default.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);
});
});runInDurableObject() を使うと、インスタンスのプロパティとストレージに直接アクセスできます。内部状態の確認や、private メソッドのテストに役立ちます。
import { env } from "cloudflare:workers";
import { 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).toBeGreaterThanOrEqual(2);
expect(ids.some((id) => id.equals(id1))).toBe(true);
expect(ids.some((id) => id.equals(id2))).toBe(true);
});
});import { env } from "cloudflare:workers";
import {
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).toBeGreaterThanOrEqual(2);
expect(ids.some((id) => id.equals(id1))).toBe(true);
expect(ids.some((id) => id.equals(id2))).toBe(true);
});
});SQLite バックエンドの Durable Objects は、テストでもそのまま使えます。Wrangler 設定で Durable Object クラスに new_sqlite_classes を指定している場合、SQL API が使えます。
import { env } from "cloudflare:workers";
import { 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 } from "cloudflare:workers";
import { 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);
});
});
});runDurableObjectAlarm() を使うと、タイマーを待たずにスケジュール済みアラームをすぐ発火できます。アラームハンドラーを同期的にテストできます。
import { env } from "cloudflare:workers";
import { 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 } from "cloudflare:workers";
import {
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);
});
});アラームをテストするには、Durable Object に alarm() メソッドを追加します。
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);
}
}evictDurableObject() を使うと、テスト中に Durable Object インスタンスを退避できます。退避するとインスタンスが破棄され、インメモリ状態がリセットされます。退避後にストレージから状態を復元する動きをテストできます。
既定では、休止可能な WebSocket は閉じられずにハイバネートされます。退避は、実行中のリクエストが終わるまで最大 30 秒待ち、そのあとインスタンスを破棄します。
次のテストは、インメモリ状態(cachedHits)と永続ストレージ(カウンター値)の両方を設定し、Durable Object を退避したあと、インメモリ状態は消えて保存済みのカウントは残ることを確認します。
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("Durable Object eviction", () => {
it("wipes in-memory state but preserves storage across eviction", async () => {
const id = env.COUNTER.idFromName("evict-test");
const stub = env.COUNTER.get(id);
// Persist a value to SQLite storage
await stub.increment();
await stub.increment();
expect(await stub.getCount()).toBe(2);
// Set in-memory only state, which is not persisted to storage
await stub.recordHit();
await stub.recordHit();
expect(await stub.getHits()).toBe(2);
// Evict the Durable Object. The in-memory instance is torn down,
// but durable storage is preserved.
await evictDurableObject(stub);
// In-memory state is wiped: the reconstructed instance starts fresh
expect(await stub.getHits()).toBe(0);
// Durable storage survives: the persisted count is read back
expect(await stub.getCount()).toBe(2);
});
});import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("Durable Object eviction", () => {
it("wipes in-memory state but preserves storage across eviction", async () => {
const id = env.COUNTER.idFromName("evict-test");
const stub = env.COUNTER.get(id);
// Persist a value to SQLite storage
await stub.increment();
await stub.increment();
expect(await stub.getCount()).toBe(2);
// Set in-memory only state, which is not persisted to storage
await stub.recordHit();
await stub.recordHit();
expect(await stub.getHits()).toBe(2);
// Evict the Durable Object. The in-memory instance is torn down,
// but durable storage is preserved.
await evictDurableObject(stub);
// In-memory state is wiped: the reconstructed instance starts fresh
expect(await stub.getHits()).toBe(0);
// Durable storage survives: the persisted count is read back
expect(await stub.getCount()).toBe(2);
});
});Durable Object を退避するとき、休止可能な WebSocket の扱いを options パラメーターで制御できます。
{ webSockets: "hibernate" }(既定)は WebSocket をハイバネートし、退避後に再開できるようにします。{ webSockets: "close" }は退避時に WebSocket を閉じます。
次の例は、Hibernatable WebSockets API で WebSocket 接続を受け付ける Durable Object を使います。
import { DurableObject } from "cloudflare:workers";
export class WebSocketServer extends DurableObject {
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
// Accept the WebSocket as hibernatable so it can survive eviction
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(ws, message) {
// Echo the received message back to the client
ws.send(message);
}
webSocketClose(ws, code, reason, wasClean) {
// Handle WebSocket close events
}
}import { DurableObject } from "cloudflare:workers";
export class WebSocketServer extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const [client, server] = Object.values(new WebSocketPair());
// Accept the WebSocket as hibernatable so it can survive eviction
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// Echo the received message back to the client
ws.send(message);
}
webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
// Handle WebSocket close events
}
}既存の COUNTER バインディングと並べて、Wrangler 設定にこの Durable Object のバインディングとマイグレーションを追加します。
{
"durable_objects": {
"bindings": [
{ "name": "WEBSOCKET_SERVER", "class_name": "WebSocketServer" }
]
},
"migrations": [
{ "tag": "v2", "new_sqlite_classes": ["WebSocketServer"] }
]
}[[durable_objects.bindings]]
name = "WEBSOCKET_SERVER"
class_name = "WebSocketServer"
[[migrations]]
tag = "v2"
new_sqlite_classes = [ "WebSocketServer" ]既定のオプションでは、休止可能な WebSocket は退避後も開いたままなので、その後もメッセージの往復ができます。{ webSockets: "close" } を渡すと、代わりに閉じます。
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("WebSocket eviction behavior", () => {
it("hibernates WebSockets across eviction by default", async () => {
const id = env.WEBSOCKET_SERVER.idFromName("ws-test");
const stub = env.WEBSOCKET_SERVER.get(id);
const response = await stub.fetch("https://example.com", {
headers: { Upgrade: "websocket" },
});
const socket = response.webSocket;
if (!socket) throw new Error("Expected WebSocket response");
socket.accept();
// Hibernatable WebSockets are hibernated, not closed
await evictDurableObject(stub);
// Messages still round-trip after eviction wakes the Durable Object
const message = new Promise((resolve) => {
socket.addEventListener("message", (event) => {
resolve(event.data);
});
});
socket.send("after-eviction");
expect(await message).toBe("after-eviction");
socket.close(1000, "done");
});
it("closes WebSockets when requested", async () => {
const id = env.WEBSOCKET_SERVER.idFromName("ws-close-test");
const stub = env.WEBSOCKET_SERVER.get(id);
const response = await stub.fetch("https://example.com", {
headers: { Upgrade: "websocket" },
});
const socket = response.webSocket;
if (!socket) throw new Error("Expected WebSocket response");
socket.accept();
const closed = new Promise((resolve) => {
socket.addEventListener("close", (event) => resolve(event));
});
// Close WebSockets instead of hibernating them
await evictDurableObject(stub, { webSockets: "close" });
expect(await closed).toBeDefined();
});
});import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("WebSocket eviction behavior", () => {
it("hibernates WebSockets across eviction by default", async () => {
const id = env.WEBSOCKET_SERVER.idFromName("ws-test");
const stub = env.WEBSOCKET_SERVER.get(id);
const response = await stub.fetch("https://example.com", {
headers: { Upgrade: "websocket" },
});
const socket = response.webSocket;
if (!socket) throw new Error("Expected WebSocket response");
socket.accept();
// Hibernatable WebSockets are hibernated, not closed
await evictDurableObject(stub);
// Messages still round-trip after eviction wakes the Durable Object
const message = new Promise<string>((resolve) => {
socket.addEventListener("message", (event) => {
resolve(event.data as string);
});
});
socket.send("after-eviction");
expect(await message).toBe("after-eviction");
socket.close(1000, "done");
});
it("closes WebSockets when requested", async () => {
const id = env.WEBSOCKET_SERVER.idFromName("ws-close-test");
const stub = env.WEBSOCKET_SERVER.get(id);
const response = await stub.fetch("https://example.com", {
headers: { Upgrade: "websocket" },
});
const socket = response.webSocket;
if (!socket) throw new Error("Expected WebSocket response");
socket.accept();
const closed = new Promise<CloseEvent>((resolve) => {
socket.addEventListener("close", (event) => resolve(event));
});
// Close WebSockets instead of hibernating them
await evictDurableObject(stub, { webSockets: "close" });
expect(await closed).toBeDefined();
});
});実行中の Durable Objects を一度にすべて退避するには(テスト間で永続データを消さずに状態をリセットする場合など)、evictAllDurableObjects() を使います。
import { evictAllDurableObjects } from "cloudflare:test";
import { afterEach } from "vitest";
afterEach(async () => {
await evictAllDurableObjects();
});退避ヘルパーの詳細(DurableObjectEvictionOptions インターフェイスを含む)は、Test APIs リファレンス を参照してください。
次のコマンドでテストを実行します。
npx vitestまたは package.json にスクリプトを追加します。
{
"scripts": {
"test": "vitest"
}
}- Workers Vitest 連携 — Vitest 連携の完全なドキュメント
- Durable Objects のテストレシピ ↗ — Workers SDK の例
- RPC のテストレシピ ↗ — Durable Objects で JSRPC をテストする