From b9e2478d1775e3754c01950a3ec4256308b8ae85 Mon Sep 17 00:00:00 2001 From: Ashley Peacock Date: Thu, 25 Jun 2026 10:38:30 +0100 Subject: [PATCH] [Durable Objects] Add docs & changelog for eviction helpers in vitest --- ...5-durable-object-eviction-test-helpers.mdx | 35 ++++ .../examples/testing-with-durable-objects.mdx | 180 +++++++++++++++++- src/content/docs/workers/testing/index.mdx | 1 + .../testing/vitest-integration/test-apis.mdx | 91 +++++++++ 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/content/changelog/workers/2026-06-25-durable-object-eviction-test-helpers.mdx diff --git a/src/content/changelog/workers/2026-06-25-durable-object-eviction-test-helpers.mdx b/src/content/changelog/workers/2026-06-25-durable-object-eviction-test-helpers.mdx new file mode 100644 index 00000000000..488b7dea813 --- /dev/null +++ b/src/content/changelog/workers/2026-06-25-durable-object-eviction-test-helpers.mdx @@ -0,0 +1,35 @@ +--- +title: Test Durable Object eviction with new cloudflare:test helpers +description: evictDurableObject and evictAllDurableObjects let you test how Durable Objects behave across evictions in your Vitest tests. +products: + - durable-objects + - workers +date: 2026-06-25 +--- + +The `@cloudflare/vitest-pool-workers` package now includes `evictDurableObject` and `evictAllDurableObjects` test helpers, exported from `cloudflare:test`. + +These helpers let you test how a Durable Object behaves across evictions, simulating the production lifecycle where an idle Durable Object can be evicted from memory. + +For more context, refer to [Lifecycle of a Durable Object](/durable-objects/concepts/durable-object-lifecycle/). + +```ts +import { evictDurableObject, evictAllDurableObjects } from "cloudflare:test"; +import { env } from "cloudflare:workers"; + +const id = env.COUNTER.idFromName("my-counter"); +const stub = env.COUNTER.get(id); + +// Evict the Durable Object instance pointed to by a specific stub +await evictDurableObject(stub); + +// Close WebSockets instead of hibernating them +await evictDurableObject(stub, { webSockets: "close" }); + +// Evict all currently-running Durable Objects in evictable namespaces +await evictAllDurableObjects(); +``` + +These helpers are available in `@cloudflare/vitest-pool-workers@0.16.20` and later. + +Learn more in the [Test APIs reference](/workers/testing/vitest-integration/test-apis/#durable-objects) and the [Testing Durable Objects guide](/durable-objects/examples/testing-with-durable-objects/#testing-eviction). diff --git a/src/content/docs/durable-objects/examples/testing-with-durable-objects.mdx b/src/content/docs/durable-objects/examples/testing-with-durable-objects.mdx index 4151b0a13ae..a17fd8ddff3 100644 --- a/src/content/docs/durable-objects/examples/testing-with-durable-objects.mdx +++ b/src/content/docs/durable-objects/examples/testing-with-durable-objects.mdx @@ -62,6 +62,19 @@ export class Counter extends DurableObject { }); } + // 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 { this.ctx.storage.sql.exec( `INSERT INTO counters (name, value) VALUES (?, 1) @@ -329,7 +342,7 @@ describe("Direct Durable Object access", () => { // List all IDs in the namespace const ids = await listDurableObjectIds(env.COUNTER); - expect(ids.length).toBe(2); + expect(ids.length).toBeGreaterThanOrEqual(2); expect(ids.some((id) => id.equals(id1))).toBe(true); expect(ids.some((id) => id.equals(id2))).toBe(true); }); @@ -444,6 +457,171 @@ export class Counter extends DurableObject { ``` +### Testing eviction + +Use `evictDurableObject()` to evict a Durable Object instance during tests. Eviction tears down the instance to reset its in-memory state. This lets you test how your Durable Object recovers state from storage after being evicted. + +By default, hibernatable WebSockets are hibernated rather than closed, and eviction waits up to 30 seconds for in-flight requests to drain before tearing down the instance. + +The following test sets both in-memory state (`cachedHits`) and durable storage (the counter value), evicts the Durable Object, and verifies that the in-memory state is wiped while the stored count survives: + + +```ts +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); + }); +}); +``` + + +#### Testing WebSocket behavior across eviction + +You can control what happens to hibernatable WebSockets when a Durable Object is evicted by passing the `options` parameter: + +- `{ webSockets: "hibernate" }` (the default) hibernates WebSockets so they can resume after eviction. +- `{ webSockets: "close" }` closes WebSockets during eviction. + +The following example uses a Durable Object that accepts WebSocket connections with the [hibernatable WebSockets API](/durable-objects/best-practices/websockets/): + + +```ts +import { DurableObject } from "cloudflare:workers"; + +export class WebSocketServer extends DurableObject { + async fetch(request: Request): Promise { + 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 + } +} +``` + + +Add a binding and migration for the Durable Object in your Wrangler configuration, alongside the existing `COUNTER` binding: + + +```jsonc +{ + "durable_objects": { + "bindings": [ + { "name": "WEBSOCKET_SERVER", "class_name": "WebSocketServer" } + ] + }, + "migrations": [ + { "tag": "v2", "new_sqlite_classes": ["WebSocketServer"] } + ] +} +``` + + +With the default options, hibernatable WebSockets remain open across eviction, so messages still round-trip afterwards. Passing `{ webSockets: "close" }` closes them instead: + + +```ts +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 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((resolve) => { + socket.addEventListener("close", (event) => resolve(event)); + }); + + // Close WebSockets instead of hibernating them + await evictDurableObject(stub, { webSockets: "close" }); + expect(await closed).toBeDefined(); + }); +}); +``` + + +To evict all currently-running Durable Objects at once (for example, to reset state between tests without deleting persisted data), use `evictAllDurableObjects()`: + +```ts +import { evictAllDurableObjects } from "cloudflare:test"; +import { afterEach } from "vitest"; + +afterEach(async () => { + await evictAllDurableObjects(); +}); +``` + +For more details on the eviction helpers, including the `DurableObjectEvictionOptions` interface, refer to the [Test APIs reference](/workers/testing/vitest-integration/test-apis/#durable-objects). + ## Running tests Run your tests with: diff --git a/src/content/docs/workers/testing/index.mdx b/src/content/docs/workers/testing/index.mdx index bf3b4465776..68b1b4ce94d 100644 --- a/src/content/docs/workers/testing/index.mdx +++ b/src/content/docs/workers/testing/index.mdx @@ -32,6 +32,7 @@ However, if you don't use Vitest, both [Miniflare's API](/workers/testing/minif | Direct access to Durable Objects | ✅ | ❌ | ❌ | | Run Durable Object alarms immediately | ✅ | ❌ | ❌ | | List Durable Objects | ✅ | ❌ | ❌ | +| Test Durable Object eviction | ✅ | ❌ | ❌ | | Testing service Workers | ❌ | ✅ | ✅ | diff --git a/src/content/docs/workers/testing/vitest-integration/test-apis.mdx b/src/content/docs/workers/testing/vitest-integration/test-apis.mdx index 14df7911574..7a0fd3a9f8d 100644 --- a/src/content/docs/workers/testing/vitest-integration/test-apis.mdx +++ b/src/content/docs/workers/testing/vitest-integration/test-apis.mdx @@ -203,6 +203,48 @@ The Workers Vitest integration provides runtime helpers for writing tests. Some * Immediately runs and removes the Durable Object pointed to by `stub`'s alarm if one is scheduled. Returns `true` if an alarm ran, and `false` otherwise. Note this can only be used with `stub`s pointing to Durable Objects defined in the `main` Worker. +* evictDurableObject(stub:DurableObjectStub, options?:DurableObjectEvictionOptions): Promise\ + + * Evicts the currently-running Durable Object pointed to by `stub`, tearing down its instance to reset in-memory state. By default, hibernatable WebSockets are hibernated rather than closed, and eviction waits up to 30 seconds for in-flight requests to drain. + +
+ + Useful for testing how a Durable Object behaves across evictions, such as recovering state from storage or resuming hibernated WebSockets. + +
+ + Rejects if `stub` is not a Durable Object stub, if the target Durable Object is not currently running, or if its namespace has eviction prevented. Note this can only be used with `stub`s pointing to Durable Objects defined in the `main` Worker. + +
+ + ```ts + import { env } from "cloudflare:workers"; + import { evictDurableObject } from "cloudflare:test"; + import { it, expect } from "vitest"; + + it("preserves stored data across eviction", async () => { + const id = env.COUNTER.idFromName("evict-test"); + const stub = env.COUNTER.get(id); + + // Each request increments and persists the count to storage + expect(await (await stub.fetch("https://example.com")).text()).toBe("1"); + expect(await (await stub.fetch("https://example.com")).text()).toBe("2"); + + // Evict the Durable Object. The in-memory instance is torn down, + // but durable storage is preserved. + await evictDurableObject(stub); + + // The next request reconstructs the instance and reads the persisted count + expect(await (await stub.fetch("https://example.com")).text()).toBe("3"); + }); + ``` + + * The `DurableObjectEvictionOptions` interface controls eviction behavior: + + | Property | Type | Default | Description | + | --- | --- | --- | --- | + | `webSockets` | `"close" \| "hibernate"` | `"hibernate"` | Controls what happens to hibernatable WebSockets when evicting a Durable Object. With `"hibernate"`, WebSockets are hibernated and can resume after eviction. With `"close"`, WebSockets are closed. | + * listDurableObjectIds(namespace:DurableObjectNamespace): Promise\ * Gets the IDs of all objects that have been created in the `namespace`. Respects per-file storage isolation, meaning objects created in a different test file will not be returned. @@ -226,6 +268,55 @@ The Workers Vitest integration provides runtime helpers for writing tests. Some }); ``` +* reset(): Promise\ + + * Deletes all data from all attached bindings. This is useful for resetting state between test blocks. + +
+ + ```ts + import { reset } from "cloudflare:test"; + import { afterEach } from "vitest"; + + afterEach(async () => { + await reset(); + }); + ``` + +* abortAllDurableObjects(): Promise\ + + * Resets all Durable Object instances. Unlike `reset()`, this does not delete persisted data. This forcibly tears down all running Durable Object instances, discarding in-memory state without waiting for in-flight requests to drain. + +
+ + ```ts + import { abortAllDurableObjects } from "cloudflare:test"; + import { afterEach } from "vitest"; + + afterEach(async () => { + await abortAllDurableObjects(); + }); + ``` + +* evictAllDurableObjects(options?:DurableObjectEvictionOptions): Promise\ + + * Evicts all currently-running Durable Objects in evictable namespaces. Unlike `abortAllDurableObjects()`, eviction is graceful: hibernatable WebSockets are hibernated rather than closed by default, and eviction waits up to 30 seconds for in-flight requests to drain. In-memory state is reset by tearing down each instance. + +
+ + Non-running or idle Durable Objects are skipped, and namespaces with eviction prevented are respected. Accepts the same [`DurableObjectEvictionOptions`](#durable-objects) as `evictDurableObject()`. + +
+ + ```ts + import { evictAllDurableObjects } from "cloudflare:test"; + import { afterEach } from "vitest"; + + afterEach(async () => { + await evictAllDurableObjects(); + }); + ``` + ### D1