From 7f286f2d7aee93b6f84bb7239f564cb77ac67077 Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Wed, 11 Mar 2026 17:26:17 +0800 Subject: [PATCH 1/8] Add changelog for vitest4 upgrade to pool-workers --- ...026-03-11-vitest-pool-workers-vitest-4.mdx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx diff --git a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx new file mode 100644 index 00000000000..6636d6a5d26 --- /dev/null +++ b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx @@ -0,0 +1,70 @@ +--- +title: "`@cloudflare/vitest-pool-workers` now requires Vitest 4" +description: The Workers Vitest integration has been rearchitected to support Vitest 4, dropping support for Vitest 2.x and 3.x. +products: + - workers +date: 2026-03-11 +--- + +`@cloudflare/vitest-pool-workers` now requires Vitest 4.1 or later. Support for Vitest 2.x and 3.x has been dropped. This release rearchitects the integration to use a Vite plugin model and simplifies the configuration and isolation APIs as the package moves toward v1. + +If you are not ready to migrate, stay on the previous version of `@cloudflare/vitest-pool-workers`. It will continue to work with Vitest 3.x and your existing Wrangler setup. However, you will not be able to use the new features and improvements introduced in Vitest 4 or future versions of Wrangler until you upgrade. + +## Run the codemod + +A codemod is available to update your config file to Vitest 4 automatically: + +```sh +npx jscodeshift -t node_modules/@cloudflare/vitest-pool-workers/dist/codemods/vitest-v3-to-v4.mjs vitest.config.ts +``` + +Or, without installing the package first: + +```sh +npx jscodeshift -t https://unpkg.com/@cloudflare/vitest-pool-workers/dist/codemods/vitest-v3-to-v4.mjs --parser=ts vitest.config.ts +``` + +## Configuration changes + +`defineWorkersProject` and `defineWorkersConfig` from `@cloudflare/vitest-pool-workers/config` have been replaced with a `cloudflareTest()` Vite plugin exported from `@cloudflare/vitest-pool-workers`. Options previously nested under `test.poolOptions.workers` are now passed directly to `cloudflareTest()`. + +Before: + +```ts title="vitest.config.ts" +import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersProject({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: "./wrangler.jsonc" }, + }, + }, + }, +}); +``` + +After: + +```ts title="vitest.config.ts" +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], +}); +``` + +## Other breaking changes + +- **`isolatedStorage` and `singleWorker` removed.** Storage isolation is now per test file, matching Vitest's own isolation model. To make test files share the same storage, use the Vitest flags `--max-workers=1 --no-isolate`. + +- **`import { env, SELF } from "cloudflare:test"` removed.** Use `import { env, exports } from "cloudflare:workers"` instead. `exports.default.fetch()` behaves the same as `SELF.fetch()`, except that it does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). + +- **`import { fetchMock } from "cloudflare:test"` removed.** Mock `globalThis.fetch` directly or use ecosystem libraries such as [MSW](https://mswjs.io/). Refer to the [request mocking example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts) for an example. + +For upstream Vitest 4 breaking changes that may affect your tests, refer to the [Vitest 4 migration guide](https://vitest.dev/guide/migration#vitest-4). If you run into issues, open a discussion on the [workers-sdk GitHub repository](https://github.com/cloudflare/workers-sdk/discussions). From eb5622664a0f811d83a4db041858bf171f9a68e9 Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Wed, 11 Mar 2026 17:26:47 +0800 Subject: [PATCH 2/8] Update docs for vitest 4.1 release --- .../rules-of-durable-objects.mdx | 672 ++++++++-------- .../examples/testing-with-durable-objects.mdx | 425 ++++++----- .../best-practices/workers-best-practices.mdx | 2 +- .../vitest-integration/configuration.mdx | 338 +++------ .../testing/vitest-integration/debugging.mdx | 82 +- .../testing/vitest-integration/index.mdx | 3 +- .../isolation-and-concurrency.mdx | 32 +- .../vitest-integration/known-issues.mdx | 108 +-- .../migrate-from-miniflare-2.mdx | 73 +- .../migrate-from-unstable-dev.mdx | 30 +- .../testing/vitest-integration/test-apis.mdx | 717 +++++++++--------- .../write-your-first-test.mdx | 83 +- 12 files changed, 1210 insertions(+), 1355 deletions(-) diff --git a/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx b/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx index 50430798145..049527ee722 100644 --- a/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx +++ b/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx @@ -42,32 +42,33 @@ export interface Env { // ✅ Good use of Durable Objects: Seat booking requires coordination // All booking requests for a venue must be serialized to prevent double-booking export class SeatBooking extends DurableObject { - async bookSeat( - seatId: string, - userId: string - ): Promise<{ success: boolean; message: string }> { - // Check if seat is already booked - const existing = this.ctx.storage.sql - .exec<{ user_id: string }>( - "SELECT user_id FROM bookings WHERE seat_id = ?", - seatId - ) - .toArray(); - - if (existing.length > 0) { - return { success: false, message: "Seat already booked" }; - } - - // Book the seat - this is safe because Durable Objects are single-threaded - this.ctx.storage.sql.exec( - "INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)", - seatId, - userId, - Date.now() - ); +async bookSeat( +seatId: string, +userId: string +): Promise<{ success: boolean; message: string }> { +// Check if seat is already booked +const existing = this.ctx.storage.sql +.exec<{ user_id: string }>( +"SELECT user_id FROM bookings WHERE seat_id = ?", +seatId +) +.toArray(); + + if (existing.length > 0) { + return { success: false, message: "Seat already booked" }; + } + + // Book the seat - this is safe because Durable Objects are single-threaded + this.ctx.storage.sql.exec( + "INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)", + seatId, + userId, + Date.now() + ); + + return { success: true, message: "Seat booked successfully" }; + } - return { success: true, message: "Seat booked successfully" }; - } } export default { @@ -75,23 +76,25 @@ export default { const url = new URL(request.url); const eventId = url.searchParams.get("event") ?? "default"; - // Route to a Durable Object by event ID - // All bookings for the same event go to the same instance - const id = env.BOOKING.idFromName(eventId); - const booking = env.BOOKING.get(id); + // Route to a Durable Object by event ID + // All bookings for the same event go to the same instance + const id = env.BOOKING.idFromName(eventId); + const booking = env.BOOKING.get(id); - const { seatId, userId } = await request.json<{ - seatId: string; - userId: string; - }>(); - const result = await booking.bookSeat(seatId, userId); + const { seatId, userId } = await request.json<{ + seatId: string; + userId: string; + }>(); + const result = await booking.bookSeat(seatId, userId); + + return Response.json(result, { + status: result.success ? 200 : 409, + }); + }, - return Response.json(result, { - status: result.success ? 200 : 409, - }); - }, }; -``` + +```` A common pattern is to use Workers as the stateless entry point that routes requests to Durable Objects when coordination is needed. The Worker handles authentication, validation, and response formatting, while the Durable Object handles the stateful logic. @@ -139,7 +142,7 @@ export default { return new Response("Message sent"); }, }; -``` +```` @@ -161,16 +164,16 @@ export interface Env { // 🔴 Bad: A single Durable Object handling ALL chat rooms export class ChatRoom extends DurableObject { - async sendMessage(roomId: string, userId: string, message: string) { - // All messages for ALL rooms go through this single instance. - // This becomes a bottleneck as traffic grows. - this.ctx.storage.sql.exec( - "INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)", - roomId, - userId, - message - ); - } +async sendMessage(roomId: string, userId: string, message: string) { +// All messages for ALL rooms go through this single instance. +// This becomes a bottleneck as traffic grows. +this.ctx.storage.sql.exec( +"INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)", +roomId, +userId, +message +); +} } export default { @@ -179,9 +182,10 @@ export default { const id = env.CHAT_ROOM.idFromName("global"); const stub = env.CHAT_ROOM.get(id); - await stub.sendMessage("room-123", "user-456", "Hello!"); - return new Response("Sent"); - }, + await stub.sendMessage("room-123", "user-456", "Hello!"); + return new Response("Sent"); + }, + }; ``` @@ -207,7 +211,7 @@ Calculate your sharding requirements: Required DOs = (Total requests/second) / (Requests per DO capacity) -``` +```` ### Use deterministic IDs for predictable routing @@ -244,7 +248,7 @@ export default { return new Response("Joined game"); }, }; -``` +```` @@ -273,14 +277,15 @@ export default { const id = env.GAME_SESSION.newUniqueId(); const stub = env.GAME_SESSION.get(id); - // Store the mapping: gameCode -> id.toString() - // await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run(); + // Store the mapping: gameCode -> id.toString() + // await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run(); + + return Response.json({ gameId: id.toString() }); + }, - return Response.json({ gameId: id.toString() }); - }, }; -``` +```` ### Use parent-child relationships for related entities @@ -358,7 +363,7 @@ export class GameMatch extends DurableObject { ); } } -``` +```` @@ -390,15 +395,16 @@ export default { const gameId = url.searchParams.get("game") ?? "default"; const region = url.searchParams.get("region") ?? "wnam"; // Western North America - // Provide a location hint for where this Durable Object should be created - const id = env.GAME_SESSION.idFromName(gameId); - const stub = env.GAME_SESSION.get(id, { locationHint: region }); + // Provide a location hint for where this Durable Object should be created + const id = env.GAME_SESSION.idFromName(gameId); + const stub = env.GAME_SESSION.get(id, { locationHint: region }); + + return new Response("Connected to game session"); + }, - return new Response("Connected to game session"); - }, }; -``` +```` Location hints are suggestions, not guarantees. Refer to [Data location](/durable-objects/reference/data-location/) for available regions and details. @@ -418,7 +424,7 @@ Configure your Durable Object class to use SQLite storage in your Wrangler confi { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ] } -``` +```` @@ -433,47 +439,48 @@ export interface Env { } type Message = { - id: number; - user_id: string; - content: string; - created_at: number; +id: number; +user_id: string; +content: string; +created_at: number; }; export class ChatRoom extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - // Create tables on first instantiation - this.ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - } - - async addMessage(userId: string, content: string) { - this.ctx.storage.sql.exec( - "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", - userId, - content, - Date.now() - ); - } + // Create tables on first instantiation + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + } + + async addMessage(userId: string, content: string) { + this.ctx.storage.sql.exec( + "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", + userId, + content, + Date.now() + ); + } + + async getRecentMessages(limit: number = 50): Promise { + // Use type parameter for typed results + const cursor = this.ctx.storage.sql.exec( + "SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", + limit + ); + return cursor.toArray(); + } - async getRecentMessages(limit: number = 50): Promise { - // Use type parameter for typed results - const cursor = this.ctx.storage.sql.exec( - "SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", - limit - ); - return cursor.toArray(); - } } -``` +```` Refer to [Access Durable Objects storage](/durable-objects/best-practices/access-durable-objects-storage/) for more details on the SQL API. @@ -550,7 +557,7 @@ export class ChatRoom extends DurableObject { } } } -``` +```` @@ -575,46 +582,47 @@ export interface Env { } type Message = { - id: number; - user_id: string; - content: string; - created_at: number; +id: number; +user_id: string; +content: string; +created_at: number; }; export class ChatRoom extends DurableObject { // In-memory cache - fast but NOT preserved across evictions or crashes private messageCache: Message[] | null = null; - async getRecentMessages(): Promise { - // Return from cache if available (only valid while DO is in memory) - if (this.messageCache !== null) { - return this.messageCache; - } - - // Otherwise, load from durable storage - const cursor = this.ctx.storage.sql.exec( - "SELECT * FROM messages ORDER BY created_at DESC LIMIT 100" - ); - this.messageCache = cursor.toArray(); - return this.messageCache; - } - - async addMessage(userId: string, content: string) { - // ✅ Always persist to durable storage first - this.ctx.storage.sql.exec( - "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", - userId, - content, - Date.now() - ); + async getRecentMessages(): Promise { + // Return from cache if available (only valid while DO is in memory) + if (this.messageCache !== null) { + return this.messageCache; + } + + // Otherwise, load from durable storage + const cursor = this.ctx.storage.sql.exec( + "SELECT * FROM messages ORDER BY created_at DESC LIMIT 100" + ); + this.messageCache = cursor.toArray(); + return this.messageCache; + } + + async addMessage(userId: string, content: string) { + // ✅ Always persist to durable storage first + this.ctx.storage.sql.exec( + "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", + userId, + content, + Date.now() + ); + + // Then update the cache (if it exists) + // If the DO crashes here, the message is still saved in SQLite + this.messageCache = null; // Invalidate cache + } - // Then update the cache (if it exists) - // If the DO crashes here, the message is still saved in SQLite - this.messageCache = null; // Invalidate cache - } } -``` +```` :::caution @@ -671,7 +679,7 @@ export class ChatRoom extends DurableObject { .toArray(); } } -``` +```` @@ -722,13 +730,14 @@ export class ChatRoom extends DurableObject { Date.now() ); - // This response is held by the output gate until the write completes. - // The client only receives "Message sent" after data is safely persisted. - return "Message sent"; - } + // This response is held by the output gate until the write completes. + // The client only receives "Message sent" after data is safely persisted. + return "Message sent"; + } + } -``` +```` **Write coalescing:** Multiple storage writes without intervening `await` calls are automatically batched into a single atomic implicit transaction: @@ -773,7 +782,7 @@ export class Account extends DurableObject { await this.ctx.storage.put(`balance:${toId}`, toBalance + amount); } } -``` +```` @@ -796,17 +805,18 @@ export class Processor extends DurableObject { async processItem(id: string) { const item = await this.ctx.storage.get<{ status: string }>(`item:${id}`); - if (item?.status === "pending") { - // During this fetch, other requests CAN execute and modify storage - const result = await fetch("https://api.example.com/process"); + if (item?.status === "pending") { + // During this fetch, other requests CAN execute and modify storage + const result = await fetch("https://api.example.com/process"); + + // Another request may have already processed this item! + await this.ctx.storage.put(`item:${id}`, { status: "completed" }); + } + } - // Another request may have already processed this item! - await this.ctx.storage.put(`item:${id}`, { status: "completed" }); - } - } } -``` +```` To handle this, use optimistic locking (check-and-set) patterns: read a version number before the external call, then verify it has not changed before writing. @@ -865,7 +875,7 @@ export class ChatRoom extends DurableObject { // Other requests can be processed concurrently } } -``` +```` @@ -897,10 +907,10 @@ export interface Env { } type Message = { - id: number; - userId: string; - content: string; - createdAt: number; +id: number; +userId: string; +content: string; +createdAt: number; }; export class ChatRoom extends DurableObject { @@ -917,21 +927,22 @@ export class ChatRoom extends DurableObject { return { id, userId, content, createdAt }; } - async getMessages(limit: number = 50): Promise { - const cursor = this.ctx.storage.sql.exec<{ - id: number; - user_id: string; - content: string; - created_at: number; - }>("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", limit); - - return cursor.toArray().map((row) => ({ - id: row.id, - userId: row.user_id, - content: row.content, - createdAt: row.created_at, - })); - } + async getMessages(limit: number = 50): Promise { + const cursor = this.ctx.storage.sql.exec<{ + id: number; + user_id: string; + content: string; + created_at: number; + }>("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", limit); + + return cursor.toArray().map((row) => ({ + id: row.id, + userId: row.user_id, + content: row.content, + createdAt: row.created_at, + })); + } + } export default { @@ -939,27 +950,28 @@ export default { const url = new URL(request.url); const roomId = url.searchParams.get("room") ?? "lobby"; - const id = env.CHAT_ROOM.idFromName(roomId); - // stub is typed as DurableObjectStub - const stub = env.CHAT_ROOM.get(id); - - if (request.method === "POST") { - const { userId, content } = await request.json<{ - userId: string; - content: string; - }>(); - // Direct method call with full type checking - const message = await stub.sendMessage(userId, content); - return Response.json(message); - } + const id = env.CHAT_ROOM.idFromName(roomId); + // stub is typed as DurableObjectStub + const stub = env.CHAT_ROOM.get(id); + + if (request.method === "POST") { + const { userId, content } = await request.json<{ + userId: string; + content: string; + }>(); + // Direct method call with full type checking + const message = await stub.sendMessage(userId, content); + return Response.json(message); + } + + // TypeScript knows getMessages() returns Promise + const messages = await stub.getMessages(100); + return Response.json(messages); + }, - // TypeScript knows getMessages() returns Promise - const messages = await stub.getMessages(100); - return Response.json(messages); - }, }; -``` +```` Refer to [Invoke methods](/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/) for more details on RPC and the legacy `fetch()` handler. @@ -1025,7 +1037,7 @@ export default { return new Response(`Room ${await stub.getRoomId()} ready`); }, }; -``` +```` @@ -1058,18 +1070,19 @@ export default { const id = env.CHAT_ROOM.idFromName("lobby"); const stub = env.CHAT_ROOM.get(id); - // 🔴 Bad: Not awaiting the call - // The message ID is lost, and any errors are swallowed - stub.sendMessage("user-123", "Hello"); + // 🔴 Bad: Not awaiting the call + // The message ID is lost, and any errors are swallowed + stub.sendMessage("user-123", "Hello"); - // ✅ Good: Properly awaited - const messageId = await stub.sendMessage("user-123", "Hello"); + // ✅ Good: Properly awaited + const messageId = await stub.sendMessage("user-123", "Hello"); + + return Response.json({ messageId }); + }, - return Response.json({ messageId }); - }, }; -``` +```` ## Error handling @@ -1122,7 +1135,7 @@ export class ChatRoom extends DurableObject { // External notification logic } } -``` +```` @@ -1148,55 +1161,56 @@ export class ChatRoom extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); - if (url.pathname === "/websocket") { - // Check for WebSocket upgrade - if (request.headers.get("Upgrade") !== "websocket") { - return new Response("Expected WebSocket", { status: 400 }); - } - - const pair = new WebSocketPair(); - const [client, server] = Object.values(pair); - - // Accept the WebSocket with Hibernation API - this.ctx.acceptWebSocket(server); - - return new Response(null, { status: 101, webSocket: client }); - } - - return new Response("Not found", { status: 404 }); - } - - // Called when a message is received (even after hibernation) - async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { - const data = typeof message === "string" ? message : "binary data"; - - // Broadcast to all connected clients - for (const client of this.ctx.getWebSockets()) { - if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send(data); - } - } - } - - // Called when a WebSocket is closed - async webSocketClose( - ws: WebSocket, - code: number, - reason: string, - wasClean: boolean - ) { - // Calling close() completes the WebSocket handshake - ws.close(code, reason); - console.log(`WebSocket closed: ${code} ${reason}`); - } + if (url.pathname === "/websocket") { + // Check for WebSocket upgrade + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("Expected WebSocket", { status: 400 }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Accept the WebSocket with Hibernation API + this.ctx.acceptWebSocket(server); + + return new Response(null, { status: 101, webSocket: client }); + } + + return new Response("Not found", { status: 404 }); + } + + // Called when a message is received (even after hibernation) + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + const data = typeof message === "string" ? message : "binary data"; + + // Broadcast to all connected clients + for (const client of this.ctx.getWebSockets()) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(data); + } + } + } + + // Called when a WebSocket is closed + async webSocketClose( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean + ) { + // Calling close() completes the WebSocket handshake + ws.close(code, reason); + console.log(`WebSocket closed: ${code} ${reason}`); + } + + // Called when a WebSocket error occurs + async webSocketError(ws: WebSocket, error: unknown) { + console.error("WebSocket error:", error); + } - // Called when a WebSocket error occurs - async webSocketError(ws: WebSocket, error: unknown) { - console.error("WebSocket error:", error); - } } -``` +```` With the Hibernation API, your Durable Object can go to sleep when there is no active JavaScript execution, but WebSocket connections remain open. When a message arrives, the Durable Object wakes up automatically. @@ -1289,7 +1303,7 @@ export class ChatRoom extends DurableObject { } } } -``` +```` @@ -1318,46 +1332,47 @@ export class GameMatch extends DurableObject { await this.ctx.storage.put("gameStarted", Date.now()); await this.ctx.storage.put("gameActive", true); - // Schedule the game to end after the duration - await this.ctx.storage.setAlarm(Date.now() + durationMs); - } - - // Called when the alarm fires - async alarm(alarmInfo?: AlarmInvocationInfo) { - const isActive = await this.ctx.storage.get("gameActive"); - - if (!isActive) { - return; // Game was already ended - } - - // End the game - await this.ctx.storage.put("gameActive", false); - await this.ctx.storage.put("gameEnded", Date.now()); - - // Calculate final scores, notify players, etc. - try { - await this.calculateFinalScores(); - } catch (err) { - // If we're almost out of retries but still have work to do, schedule a new alarm - // rather than letting our retries run out to ensure we keep getting invoked. - if (alarmInfo && alarmInfo.retryCount >= 5) { - await this.ctx.storage.setAlarm(Date.now() + 30 * 1000); - return; - } - throw err; - } - - // Schedule the next alarm only if there's more work to do - // In this case, schedule cleanup in 24 hours - await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); - } + // Schedule the game to end after the duration + await this.ctx.storage.setAlarm(Date.now() + durationMs); + } + + // Called when the alarm fires + async alarm(alarmInfo?: AlarmInvocationInfo) { + const isActive = await this.ctx.storage.get("gameActive"); + + if (!isActive) { + return; // Game was already ended + } + + // End the game + await this.ctx.storage.put("gameActive", false); + await this.ctx.storage.put("gameEnded", Date.now()); + + // Calculate final scores, notify players, etc. + try { + await this.calculateFinalScores(); + } catch (err) { + // If we're almost out of retries but still have work to do, schedule a new alarm + // rather than letting our retries run out to ensure we keep getting invoked. + if (alarmInfo && alarmInfo.retryCount >= 5) { + await this.ctx.storage.setAlarm(Date.now() + 30 * 1000); + return; + } + throw err; + } + + // Schedule the next alarm only if there's more work to do + // In this case, schedule cleanup in 24 hours + await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); + } + + private async calculateFinalScores() { + // Game ending logic + } - private async calculateFinalScores() { - // Game ending logic - } } -``` +```` ### Make alarm handlers idempotent @@ -1404,7 +1419,7 @@ export class Subscription extends DurableObject { return true; } } -``` +```` @@ -1423,15 +1438,16 @@ export interface Env { export class ChatRoom extends DurableObject { async clearStorage() { - // Delete all storage, including any set alarm - await this.ctx.storage.deleteAll(); + // Delete all storage, including any set alarm + await this.ctx.storage.deleteAll(); + + // The Durable Object instance still exists, but with empty storage + // A subsequent request will find no data + } - // The Durable Object instance still exists, but with empty storage - // A subsequent request will find no data - } } -``` +```` ### Design for unexpected shutdowns @@ -1484,7 +1500,7 @@ export default { return new Response("OK"); }, }; -``` +```` @@ -1498,63 +1514,63 @@ Use `@cloudflare/vitest-pool-workers` for testing Durable Objects. The integrati ```ts +import { env } from "cloudflare:workers"; import { - env, runInDurableObject, runDurableObjectAlarm, } from "cloudflare:test"; import { describe, it, expect } from "vitest"; describe("ChatRoom", () => { - // Each test gets isolated storage automatically - it("should send and retrieve messages", async () => { - const id = env.CHAT_ROOM.idFromName("test-room"); - const stub = env.CHAT_ROOM.get(id); +// Each test gets isolated storage automatically +it("should send and retrieve messages", async () => { +const id = env.CHAT_ROOM.idFromName("test-room"); +const stub = env.CHAT_ROOM.get(id); + + // Call RPC methods directly on the stub + await stub.sendMessage("user-1", "Hello!"); + await stub.sendMessage("user-2", "Hi there!"); + + const messages = await stub.getMessages(10); + expect(messages).toHaveLength(2); + }); + + it("can access instance internals and trigger alarms", async () => { + const id = env.CHAT_ROOM.idFromName("test-room"); + const stub = env.CHAT_ROOM.get(id); + + // Access storage directly for verification + await runInDurableObject(stub, async (instance, state) => { + const count = state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) as count FROM messages") + .one(); + expect(count.count).toBe(0); // Fresh instance due to test isolation + }); + + // Trigger alarms immediately without waiting + const alarmRan = await runDurableObjectAlarm(stub); + expect(alarmRan).toBe(false); // No alarm was scheduled + }); - // Call RPC methods directly on the stub - await stub.sendMessage("user-1", "Hello!"); - await stub.sendMessage("user-2", "Hi there!"); - - const messages = await stub.getMessages(10); - expect(messages).toHaveLength(2); - }); - - it("can access instance internals and trigger alarms", async () => { - const id = env.CHAT_ROOM.idFromName("test-room"); - const stub = env.CHAT_ROOM.get(id); - - // Access storage directly for verification - await runInDurableObject(stub, async (instance, state) => { - const count = state.storage.sql - .exec<{ count: number }>("SELECT COUNT(*) as count FROM messages") - .one(); - expect(count.count).toBe(0); // Fresh instance due to test isolation - }); - - // Trigger alarms immediately without waiting - const alarmRan = await runDurableObjectAlarm(stub); - expect(alarmRan).toBe(false); // No alarm was scheduled - }); }); -``` +```` Configure Vitest in your `vitest.config.ts`: ```ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.jsonc" }, - }, - }, - }, +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], }); -``` +```` For schema changes, run migrations in the constructor using `blockConcurrencyWhile()`. For class renames or deletions, use Wrangler migrations: 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 857312fb7de..e66f218c56b 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 @@ -17,21 +17,15 @@ Use the [`@cloudflare/vitest-pool-workers`](https://www.npmjs.com/package/@cloud Install Vitest and the Workers Vitest integration as dev dependencies: - -```sh -npm i -D vitest@~3.2.0 @cloudflare/vitest-pool-workers -``` - - -```sh -pnpm add -D vitest@~3.2.0 @cloudflare/vitest-pool-workers -``` - - -```sh -yarn add -D vitest@~3.2.0 @cloudflare/vitest-pool-workers -``` - + + ```sh npm i -D vitest@^4.1.0 @cloudflare/vitest-pool-workers ``` + + + ```sh pnpm add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers ``` + + + ```sh yarn add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers ``` + ## Example Durable Object @@ -50,38 +44,39 @@ export class Counter extends DurableObject { 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 { - 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 { - const result = this.ctx.storage.sql - .exec<{ value: number }>("SELECT value FROM counters WHERE name = ?", name) - .toArray(); - return result[0]?.value ?? 0; - } + 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 { + 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 { + 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 { + this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name); + } - async reset(name: string = "default"): Promise { - this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name); - } } export default { @@ -89,38 +84,39 @@ export default { 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); + 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 }); - } + if (request.method === "POST") { + const count = await stub.increment(); + return Response.json({ count }); + } + + const count = await stub.getCount(); + return Response.json({ count }); + }, - const count = await stub.getCount(); - return Response.json({ count }); - }, }; -``` + +```` ## Configure Vitest -Create a `vitest.config.ts` file that uses `defineWorkersConfig`: +Create a `vitest.config.ts` file that uses the `cloudflareTest()` plugin: ```ts title="vitest.config.ts" -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.jsonc" }, - }, - }, - }, +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], }); -``` +```` Make sure your Wrangler configuration includes the Durable Object binding and SQLite migration: @@ -151,9 +147,9 @@ Create a `test/tsconfig.json` to configure TypeScript for your tests: "extends": "../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", - "types": ["@cloudflare/vitest-pool-workers"] + "types": ["@cloudflare/vitest-pool-workers"], }, - "include": ["./**/*.ts", "../src/worker-configuration.d.ts"] + "include": ["./**/*.ts", "../src/worker-configuration.d.ts"], } ``` @@ -169,86 +165,88 @@ declare module "cloudflare:test" { ### Unit tests with direct Durable Object access -You can get a stub to a Durable Object directly from the `env` object provided by `cloudflare:test`: +You can get a stub to a Durable Object directly from the `env` object provided by `cloudflare:workers`: ```ts -import { env } from "cloudflare:test"; +import { env } from "cloudflare:workers"; 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); +// 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); + // 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 count2 = await stub.increment(); + expect(count2).toBe(2); - const count3 = await stub.increment(); - expect(count3).toBe(3); - }); + 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); + 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"); + 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); - }); + 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); + 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.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); - }); + 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"); + 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); + const stub1 = env.COUNTER.get(id1); + const stub2 = env.COUNTER.get(id2); - await stub1.increment(); - await stub1.increment(); - await stub2.increment(); + 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); + }); - // Each Durable Object instance has its own storage - expect(await stub1.getCount()).toBe(2); - expect(await stub2.getCount()).toBe(1); - }); }); -``` + +```` -### Integration tests with SELF +### Integration tests with exports -Use the `SELF` fetcher to test your Worker's HTTP handler, which routes requests to Durable Objects: +Use the `exports` binding to test your Worker's HTTP handler, which routes requests to Durable Objects: ```ts -import { SELF } from "cloudflare:test"; +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 SELF.fetch("http://example.com?id=http-test", { + const response = await exports.default.fetch("http://example.com?id=http-test", { method: "POST", }); @@ -259,22 +257,22 @@ describe("Counter Worker integration", () => { 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" }); + 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 SELF.fetch("http://example.com?id=get-test"); + 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 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" }); + 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 SELF.fetch("http://example.com?id=counter-a"); - const responseB = await SELF.fetch("http://example.com?id=counter-b"); + 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 }>(); @@ -283,7 +281,8 @@ describe("Counter Worker integration", () => { expect(dataB.count).toBe(1); }); }); -``` +```` + ### Direct access to Durable Object internals @@ -292,8 +291,8 @@ Use `runInDurableObject()` to access instance properties and storage directly. T ```ts +import { env } from "cloudflare:workers"; import { - env, runInDurableObject, listDurableObjectIds, } from "cloudflare:test"; @@ -301,46 +300,48 @@ 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 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); + }); - 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); - }); }); -``` + +```` ### Test isolation @@ -349,7 +350,8 @@ Each test automatically gets isolated storage. Durable Objects created in one te ```ts -import { env, listDurableObjectIds } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { listDurableObjectIds } from "cloudflare:test"; import { describe, it, expect } from "vitest"; describe("Test isolation", () => { @@ -373,7 +375,8 @@ describe("Test isolation", () => { expect(await stub.getCount()).toBe(0); }); }); -``` +```` + ### Testing SQLite storage @@ -382,37 +385,40 @@ SQLite-backed Durable Objects work seamlessly in tests. The SQL API is available ```ts -import { env, runInDurableObject } from "cloudflare:test"; +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"); +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); + }); + }); - // 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); - }); - }); }); -``` + +```` ### Testing alarms @@ -421,8 +427,8 @@ Use `runDurableObjectAlarm()` to immediately trigger a scheduled alarm without w ```ts +import { env } from "cloudflare:workers"; import { - env, runInDurableObject, runDurableObjectAlarm, } from "cloudflare:test"; @@ -457,7 +463,8 @@ describe("Durable Object alarms", () => { expect(alarmRanAgain).toBe(false); }); }); -``` +```` + To test alarms, add an `alarm()` method to your Durable Object: @@ -469,17 +476,19 @@ 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 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); + } - async scheduleReset(afterMs: number) { - await this.ctx.storage.setAlarm(Date.now() + afterMs); - } } -``` + +```` ## Running tests @@ -488,7 +497,7 @@ Run your tests with: ```sh npx vitest -``` +```` Or add a script to your `package.json`: diff --git a/src/content/docs/workers/best-practices/workers-best-practices.mdx b/src/content/docs/workers/best-practices/workers-best-practices.mdx index 78449a1148f..00c71010825 100644 --- a/src/content/docs/workers/best-practices/workers-best-practices.mdx +++ b/src/content/docs/workers/best-practices/workers-best-practices.mdx @@ -918,7 +918,7 @@ One known pitfall: the Vitest pool automatically injects `nodejs_compat`, so tes ```ts import { describe, it, expect } from "vitest"; -import { env } from "cloudflare:test"; +import { env } from "cloudflare:workers"; describe("KV operations", () => { it("should store and retrieve a value", async () => { diff --git a/src/content/docs/workers/testing/vitest-integration/configuration.mdx b/src/content/docs/workers/testing/vitest-integration/configuration.mdx index fd1430411b3..7211bd492d5 100644 --- a/src/content/docs/workers/testing/vitest-integration/configuration.mdx +++ b/src/content/docs/workers/testing/vitest-integration/configuration.mdx @@ -9,23 +9,22 @@ description: Vitest configuration specific to the Workers integration. import { Details } from "~/components"; -The Workers Vitest integration provides additional configuration on top of Vitest's usual options using the [`defineWorkersConfig()`](/workers/testing/vitest-integration/configuration/#defineworkersconfigoptions) API. +The Workers Vitest integration provides additional configuration on top of Vitest's usual options using the `cloudflareTest()` Vite plugin. An example configuration would be: ```ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { - configPath: "./wrangler.toml", - }, +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", }, - }, - }, + }), + ], }); ``` @@ -37,205 +36,92 @@ Custom Vitest `environment`s or `runner`s are not supported when using the Worke ## APIs -The following APIs are exported from the `@cloudflare/vitest-pool-workers/config` module. +The following APIs are exported from the `@cloudflare/vitest-pool-workers` package. -### `defineWorkersConfig(options)` +### `cloudflareTest(options)` -Ensures Vitest is configured to use the Workers integration with the correct module resolution settings, and provides type checking for [WorkersPoolOptions](#workerspooloptions). This should be used in place of the [`defineConfig()`](https://vitest.dev/config/file.html) function from Vitest. +A Vite plugin that configures Vitest to use the Workers integration with the correct module resolution settings, and provides type checking for [CloudflareTestOptions](#cloudflaretestoptions). Add this to the `plugins` array in your Vitest config alongside [`defineConfig()`](https://vitest.dev/config/file.html) from Vitest. -It also accepts a `Promise` of `options`, or an optionally-`async` function returning `options`. +It also accepts an optionally-`async` function returning `options`. ```ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - // Refer to type of WorkersPoolOptions... - }, - }, - }, +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + // Refer to CloudflareTestOptions... + }), + ], }); ``` -### `defineWorkersProject(options)` - -Use [`defineWorkersProject`](#defineworkersprojectoptions) with [Vitest Workspaces](https://vitest.dev/guide/workspace) to specify a different configuration for certain tests. It should be used in place of the [`defineProject()`](https://vitest.dev/guide/workspace) function from Vitest. - -Similar to [`defineWorkersConfig()`](#defineworkersconfigoptions), this ensures Vitest is configured to use the Workers integration with the correct module resolution settings, and provides type checking for [WorkersPoolOptions](#workerspooloptions). - -It also accepts a `Promise` of `options`, or an optionally-`async` function returning `options`. - -```ts -import { defineWorkspace, defineProject } from "vitest/config"; -import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config"; - -const workspace = defineWorkspace([ - defineWorkersProject({ - test: { - name: "Workers", - include: ["**/*.worker.test.ts"], - poolOptions: { - workers: { - // Refer to type of WorkersPoolOptions... - }, - }, - }, - }), - - // ... -]); - -export default workspace; -``` - ### `buildPagesASSETSBinding(assetsPath)` -Creates a Pages ASSETS binding that serves files insides the `assetsPath`. This is required if you uses `createPagesEventContext()` or `SELF` to test your **Pages Functions**. Refer to the [Pages recipe](/workers/testing/vitest-integration/recipes) for a full example. +Exported from `@cloudflare/vitest-pool-workers/config`. Creates a Pages ASSETS binding that serves files inside the `assetsPath`. This is required if you use `createPagesEventContext()` to test your **Pages Functions**. Refer to the [Pages recipe](/workers/testing/vitest-integration/recipes) for a full example. ```ts import path from "node:path"; -import { - buildPagesASSETSBinding, - defineWorkersProject, -} from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersProject(async () => { - const assetsPath = path.join(__dirname, "public"); - - return { - test: { - poolOptions: { - workers: { - miniflare: { - serviceBindings: { - ASSETS: await buildPagesASSETSBinding(assetsPath), - }, +import { buildPagesASSETSBinding } from "@cloudflare/vitest-pool-workers/config"; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => { + const assetsPath = path.join(__dirname, "public"); + + return { + miniflare: { + serviceBindings: { + ASSETS: await buildPagesASSETSBinding(assetsPath), }, }, - }, - }, - }; + }; + }), + ], }); ``` ### `readD1Migrations(migrationsPath)` -Reads all [D1 migrations](/d1/reference/migrations/) stored at `migrationsPath` and returns them ordered by migration number. Each migration will have its contents split into an array of individual SQL queries. Call the [`applyD1Migrations()`](/workers/testing/vitest-integration/test-apis/#d1) function inside a test or [setup file](https://vitest.dev/config/#setupfiles) to apply migrations. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations. +Exported from `@cloudflare/vitest-pool-workers/config`. Reads all [D1 migrations](/d1/reference/migrations/) stored at `migrationsPath` and returns them ordered by migration number. Each migration will have its contents split into an array of individual SQL queries. Call the [`applyD1Migrations()`](/workers/testing/vitest-integration/test-apis/#d1) function inside a test or [setup file](https://vitest.dev/config/#setupfiles) to apply migrations. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations. ```ts import path from "node:path"; -import { - defineWorkersProject, - readD1Migrations, -} from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersProject(async () => { - // Read all migrations in the `migrations` directory - const migrationsPath = path.join(__dirname, "migrations"); - const migrations = await readD1Migrations(migrationsPath); - - return { - test: { - setupFiles: ["./test/apply-migrations.ts"], - poolOptions: { - workers: { - miniflare: { - // Add a test-only binding for migrations, so we can apply them in a setup file - bindings: { TEST_MIGRATIONS: migrations }, - }, +import { readD1Migrations } from "@cloudflare/vitest-pool-workers/config"; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest(async () => { + const migrationsPath = path.join(__dirname, "migrations"); + const migrations = await readD1Migrations(migrationsPath); + + return { + miniflare: { + // Add a test-only binding for migrations, so we can apply them in a setup file + bindings: { TEST_MIGRATIONS: migrations }, }, - }, - }, - }; + }; + }), + ], + test: { + setupFiles: ["./test/apply-migrations.ts"], + }, }); ``` -## `WorkersPoolOptions` +## `CloudflareTestOptions` -- `main`: string optional +Options passed directly to `cloudflareTest()`. - - Entry point to Worker run in the same isolate/context as tests. This option is required to use `import { SELF } from "cloudflare:test"` for integration tests, or Durable Objects without an explicit `scriptName` if classes are defined in the same Worker. This file goes through Vite transforms and can be TypeScript. Note that `import module from ""` inside tests gives exactly the same `module` instance as is used internally for the `SELF` and Durable Object bindings. If `wrangler.configPath` is defined and this option is not, it will be read from the `main` field in that configuration file. - -- `isolatedStorage`: boolean optional - - - Enables per-test isolated storage. If enabled, any writes to storage performed in a test will be undone at the end of the test. The test's storage environment is copied from the containing suite, meaning `beforeAll()` hooks can be used to seed data. If this option is disabled, all tests will share the same storage. `.concurrent` tests are not supported when isolated storage is enabled. Refer to [Isolation and concurrency](/workers/testing/vitest-integration/isolation-and-concurrency/) for more information on the isolation model. - - - Defaults to `true`. - -
- - ```ts - import { env } from "cloudflare:test"; - import { beforeAll, beforeEach, describe, test, expect } from "vitest"; - - // Get the current list stored in a KV namespace - async function get(): Promise { - return (await env.NAMESPACE.get("list", "json")) ?? []; - } - // Add an item to the end of the list - async function append(item: string) { - const value = await get(); - value.push(item); - await env.NAMESPACE.put("list", JSON.stringify(value)); - } - - beforeAll(() => append("all")); - beforeEach(() => append("each")); - - test("one", async () => { - // Each test gets its own storage environment copied from the parent - await append("one"); - expect(await get()).toStrictEqual(["all", "each", "one"]); - }); - // `append("each")` and `append("one")` undone - test("two", async () => { - await append("two"); - expect(await get()).toStrictEqual(["all", "each", "two"]); - }); - // `append("each")` and `append("two")` undone - - describe("describe", async () => { - beforeAll(() => append("describe all")); - beforeEach(() => append("describe each")); - - test("three", async () => { - await append("three"); - expect(await get()).toStrictEqual([ - // All `beforeAll()`s run before `beforeEach()`s - "all", - "describe all", - "each", - "describe each", - "three", - ]); - }); - // `append("each")`, `append("describe each")` and `append("three")` undone - test("four", async () => { - await append("four"); - expect(await get()).toStrictEqual([ - "all", - "describe all", - "each", - "describe each", - "four", - ]); - }); - // `append("each")`, `append("describe each")` and `append("four")` undone - }); - ``` - -
- -- `singleWorker`: boolean optional - - - Runs all tests in this project serially in the same Worker, using the same module cache. This can significantly speed up execution if you have lots of small test files. Refer to the [Isolation and concurrency](/workers/testing/vitest-integration/isolation-and-concurrency/) page for more information on the isolation model. - - - Defaults to `false`. +- `main`: string optional + - Entry point to Worker run in the same isolate/context as tests. This option is required to use Durable Objects without an explicit `scriptName` if classes are defined in the same Worker. This file goes through Vite transforms and can be TypeScript. Note that `import module from ""` inside tests gives exactly the same `module` instance as is used internally for `exports` and Durable Object bindings. If `wrangler.configPath` is defined and this option is not, it will be read from the `main` field in that configuration file. - `miniflare`: `SourcelessWorkerOptions & { workers?: WorkerOptions\[]; }` optional - - Use this to provide configuration information that is typically stored within the [Wrangler configuration file](/workers/wrangler/configuration/), such as [bindings](/workers/runtime-apis/bindings/), [compatibility dates](/workers/configuration/compatibility-dates/), and [compatibility flags](/workers/configuration/compatibility-flags/). The `WorkerOptions` interface is defined [here](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare#interface-workeroptions). Use the `main` option above to configure the entry point, instead of the Miniflare `script`, `scriptPath`, or `modules` options. - If your project makes use of multiple Workers, you can configure auxiliary Workers that run in the same `workerd` process as your tests and can be bound to. Auxiliary Workers are configured using the `workers` array, containing regular Miniflare [`WorkerOptions`](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare#interface-workeroptions) objects. Note that unlike the `main` Worker, auxiliary Workers: @@ -247,57 +133,55 @@ export default defineWorkersProject(async () => { - Are not affected by global mocks defined in your tests. - `wrangler`: `{ configPath?: string; environment?: string; }` optional - - Path to [Wrangler configuration file](/workers/wrangler/configuration/) to load `main`, [compatibility settings](/workers/configuration/compatibility-dates/) and [bindings](/workers/runtime-apis/bindings/) from. These options will be merged with the `miniflare` option above, with `miniflare` values taking precedence. For example, if your Wrangler configuration defined a [service binding](/workers/runtime-apis/bindings/service-bindings/) named `SERVICE` to a Worker named `service`, but you included `serviceBindings: { SERVICE(request) { return new Response("body"); } }` in the `miniflare` option, all requests to `SERVICE` in tests would return `body`. Note `configPath` accepts both `.toml` and `.json` files. - The environment option can be used to specify the [Wrangler environment](/workers/wrangler/environments/) to pick up bindings and variables from. -## `WorkersPoolOptionsContext` - -- `inject`: typeof import("vitest").inject - - - The same `inject()` function usually imported from the `vitest` module inside tests. This allows you to define `miniflare` configuration based on injected values from [`globalSetup`](https://vitest.dev/config/#globalsetup) scripts. Use this if you have a value in your configuration that is dynamically generated and only known at runtime of your tests. For example, a global setup script might start an upstream server on a random port. This port could be `provide()`d and then `inject()`ed in the configuration for an external service binding or [Hyperdrive](/hyperdrive/). Refer to the [Hyperdrive recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/hyperdrive) for an example project using this provide/inject approach. - -
- - ```ts - // env.d.ts - declare module "vitest" { - interface ProvidedContext { - port: number; - } - } - - // global-setup.ts - import type { GlobalSetupContext } from "vitest/node"; - export default function ({ provide }: GlobalSetupContext) { - // Runs inside Node.js, could start server here... - provide("port", 1337); - return () => { - /* ...then teardown here */ - }; - } - - // vitest.config.ts - import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - export default defineWorkersConfig({ - test: { - globalSetup: ["./global-setup.ts"], - pool: "@cloudflare/vitest-pool-workers", - poolOptions: { - workers: ({ inject }) => ({ - miniflare: { - hyperdrives: { - DATABASE: `postgres://user:pass@example.com:${inject("port")}/db`, - }, - }, - }), - }, - }, - }); - ``` - -
+## Dynamic configuration with `inject` + +You can pass an `async` function to `cloudflareTest()` that receives an `inject` function. This allows you to define `miniflare` configuration based on injected values from [`globalSetup`](https://vitest.dev/config/#globalsetup) scripts. Use this if you have a value in your configuration that is dynamically generated and only known at runtime of your tests. For example, a global setup script might start an upstream server on a random port. This port could be `provide()`d and then `inject()`ed in the configuration for an external service binding or [Hyperdrive](/hyperdrive/). Refer to the [Hyperdrive recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/hyperdrive) for an example project using this provide/inject approach. + +
+ +```ts +// env.d.ts +declare module "vitest" { + interface ProvidedContext { + port: number; + } +} + +// global-setup.ts +import type { GlobalSetupContext } from "vitest/node"; +export default function ({ provide }: GlobalSetupContext) { + // Runs inside Node.js, could start server here... + provide("port", 1337); + return () => { + /* ...then teardown here */ + }; +} + +// vitest.config.ts +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest(({ inject }) => ({ + miniflare: { + hyperdrives: { + DATABASE: `postgres://user:pass@example.com:${inject("port")}/db`, + }, + }, + })), + ], + test: { + globalSetup: ["./global-setup.ts"], + }, +}); +``` + +
## `SourcelessWorkerOptions` diff --git a/src/content/docs/workers/testing/vitest-integration/debugging.mdx b/src/content/docs/workers/testing/vitest-integration/debugging.mdx index 6ef97d4553c..f41ccfd46d9 100644 --- a/src/content/docs/workers/testing/vitest-integration/debugging.mdx +++ b/src/content/docs/workers/testing/vitest-integration/debugging.mdx @@ -28,19 +28,20 @@ vitest --inspect=3456 --no-file-parallelism Alternatively, you can define it in your Vitest configuration file: ```ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; -export default defineWorkersConfig({ - test: { - inspector: { - port: 3456, - }, - poolOptions: { - workers: { - // ... - }, - }, - }, +export default defineConfig({ + plugins: [ + cloudflareTest({ + // ... + }), + ], + test: { + inspector: { + port: 3456, + }, + }, }); ``` @@ -50,33 +51,36 @@ To setup VS Code for breakpoint debugging in your Worker tests, create a `.vscod ```json { - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Open inspector with Vitest", - "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", - "console": "integratedTerminal", - "args": ["--inspect=9229", "--no-file-parallelism"] - }, - { - "name": "Attach to Workers Runtime", - "type": "node", - "request": "attach", - "port": 9229, - "cwd": "/", - "resolveSourceMapLocations": null, - "attachExistingChildren": false, - "autoAttachChildProcesses": false, - } - ], - "compounds": [ - { - "name": "Debug Workers tests", - "configurations": ["Open inspector with Vitest", "Attach to Workers Runtime"], - "stopAll": true - } - ] + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Open inspector with Vitest", + "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", + "console": "integratedTerminal", + "args": ["--inspect=9229", "--no-file-parallelism"] + }, + { + "name": "Attach to Workers Runtime", + "type": "node", + "request": "attach", + "port": 9229, + "cwd": "/", + "resolveSourceMapLocations": null, + "attachExistingChildren": false, + "autoAttachChildProcesses": false + } + ], + "compounds": [ + { + "name": "Debug Workers tests", + "configurations": [ + "Open inspector with Vitest", + "Attach to Workers Runtime" + ], + "stopAll": true + } + ] } ``` diff --git a/src/content/docs/workers/testing/vitest-integration/index.mdx b/src/content/docs/workers/testing/vitest-integration/index.mdx index bed779b4045..94614336cc2 100644 --- a/src/content/docs/workers/testing/vitest-integration/index.mdx +++ b/src/content/docs/workers/testing/vitest-integration/index.mdx @@ -13,10 +13,9 @@ The Workers Vitest integration: - Supports both **unit tests** and **integration tests**. - Provides direct access to Workers runtime APIs and bindings. -- Implements isolated per-test storage. +- Implements isolated per-test-file storage. - Runs tests fully-locally using [Miniflare](https://miniflare.dev/). - Leverages Vitest's hot-module reloading for near instant reruns. -- Provides a declarative interface for mocking outbound requests. - Supports projects with multiple Workers. diff --git a/src/content/docs/workers/testing/vitest-integration/isolation-and-concurrency.mdx b/src/content/docs/workers/testing/vitest-integration/isolation-and-concurrency.mdx index bbf6bf08123..b9152a67412 100644 --- a/src/content/docs/workers/testing/vitest-integration/isolation-and-concurrency.mdx +++ b/src/content/docs/workers/testing/vitest-integration/isolation-and-concurrency.mdx @@ -23,35 +23,11 @@ When you run your tests with the Workers Vitest integration, Vitest will: 5. Run [`setupFiles`](https://vitest.dev/config/#setupfiles) and test files in `workerd` using the appropriate Workers. 6. Watch for changes and re-run test files using the same Workers if the configuration has not changed. -## Isolation and concurrency models +## Isolation model -The [`isolatedStorage` and `singleWorker`](/workers/testing/vitest-integration/configuration/#workerspooloptions) configuration options both control isolation and concurrency. The Workers Vitest integration tries to minimise the number of `workerd` processes it starts, reusing Workers and their module caches between test runs where possible. The current implementation of isolated storage requires each `workerd` process to run one test file at a time, and does not support `.concurrent` tests. A copy of all auxiliary `workers` exists in each `workerd` process. +Storage isolation is per test file. Each test file gets its own storage environment, and any writes to storage during a test file are not visible to other test files. The Workers Vitest integration reuses Workers and their module caches between test runs where possible. A copy of all auxiliary `workers` exists in each `workerd` process. -By default, the `isolatedStorage` option is enabled. We recommend you enable the `singleWorker: true` option if you have lots of small test files. - -### `isolatedStorage: true, singleWorker: false` (Default) - -In this model, a `workerd` process is started for each test file. Test files are executed concurrently but `.concurrent` tests are not supported. Each test will read/write from an isolated storage environment, and bind to its own set of auxiliary `workers`. - -![Isolation Model: Isolated Storage & No Single Worker](~/assets/images/workers/testing/vitest/isolation-model-3-isolated-storage-no-single-worker.svg) - -### `isolatedStorage: true, singleWorker: true` - -In this model, a single `workerd` process is started with a single Worker for all test files. Test files are executed in serial and `.concurrent` tests are not supported. Each test will read/write from an isolated storage environment, and bind to the same auxiliary `workers`. - -![Isolation Model: Isolated Storage & Single Worker](~/assets/images/workers/testing/vitest/isolation-model-4-isolated-storage-single-worker.svg) - -### `isolatedStorage: false, singleWorker: false` - -In this model, a single `workerd` process is started with a Worker for each test file. Tests files are executed concurrently and `.concurrent` tests are supported. Every test will read/write from the same shared storage, and bind to the same auxiliary `workers`. - -![Isolation Model: No Isolated Storage & No Single Worker](~/assets/images/workers/testing/vitest/isolation-model-1-no-isolated-storage-no-single-worker.svg) - -### `isolatedStorage: false, singleWorker: true` - -In this model, a single `workerd` process is started with a single Worker for all test files. Test files are executed in serial but `.concurrent` tests are supported. Every test will read/write from the same shared storage, and bind to the same auxiliary `workers`. - -![Isolation Model: No Isolated Storage & Single Worker](~/assets/images/workers/testing/vitest/isolation-model-2-no-isolated-storage-single-worker.svg) +By default, test files run concurrently. To make test files share the same storage (for example, for integration tests that depend on shared state), use the Vitest flags `--max-workers=1 --no-isolate`. ## Modules @@ -94,7 +70,7 @@ The test is a simple assertion that the Worker managed to use `process`. ```typescript it('responds with "test"', async () => { - const response = await SELF.fetch("https://example.com/"); + const response = await exports.default.fetch("https://example.com/"); expect(await response.text()).toMatchInlineSnapshot(`"test"`); }); ``` diff --git a/src/content/docs/workers/testing/vitest-integration/known-issues.mdx b/src/content/docs/workers/testing/vitest-integration/known-issues.mdx index c327fda75a5..df3ea817044 100644 --- a/src/content/docs/workers/testing/vitest-integration/known-issues.mdx +++ b/src/content/docs/workers/testing/vitest-integration/known-issues.mdx @@ -5,7 +5,6 @@ sidebar: order: 9 head: [] description: Explore the known issues associated with the Workers Vitest integration. - --- The Workers Vitest pool is currently in open beta. The following are issues Cloudflare is aware of and fixing: @@ -18,9 +17,9 @@ Native code coverage via [V8](https://v8.dev/blog/javascript-code-coverage) is n Vitest's [fake timers](https://vitest.dev/guide/mocking.html#timers) do not apply to KV, R2 and cache simulators. For example, you cannot expire a KV key by advancing fake time. -### Dynamic `import()` statements with `SELF` and Durable Objects +### Dynamic `import()` statements with `exports` and Durable Objects -Dynamic `import()` statements do not work inside `export default { ... }` handlers when writing integration tests with `SELF`, or inside Durable Object event handlers. You must import and call your handlers directly, or use static `import` statements in the global scope. +Dynamic `import()` statements do not work inside `export default { ... }` handlers when writing integration tests with `exports.default.fetch()`, or inside Durable Object event handlers. You must import and call your handlers directly, or use static `import` statements in the global scope. ### Durable Object alarms @@ -28,11 +27,11 @@ Durable Object alarms are not reset between test runs and do not respect isolate ### WebSockets -Using WebSockets with Durable Objects with the [`isolatedStorage`](/workers/testing/vitest-integration/isolation-and-concurrency) flag turned on is not supported. You must set `isolatedStorage: false` in your `vitest.config.ts` file. +Using WebSockets with Durable Objects is not supported with per-file storage isolation. To work around this, run your tests with shared storage using `--max-workers=1 --no-isolate`. -### Isolated storage +### Storage isolation -When the `isolatedStorage` flag is enabled (the default), the test runner will undo any writes to the storage at the end of the test as detailed in the [isolation and concurrency documentation](/workers/testing/vitest-integration/isolation-and-concurrency/). However, Cloudflare recommends that you consider the following actions to avoid any common issues: +Storage isolation is per test file. The test runner will undo any writes to storage at the end of each test file as detailed in the [isolation and concurrency documentation](/workers/testing/vitest-integration/isolation-and-concurrency/). Cloudflare recommends the following actions to avoid common issues: #### Await all storage operations @@ -41,8 +40,8 @@ Always `await` all `Promise`s that read or write to storage services. ```ts // Example: Seed data beforeAll(async () => { - await env.KV.put('message', 'test message'); - await env.R2.put('file', 'hello-world'); + await env.KV.put("message", "test message"); + await env.R2.put("file", "hello-world"); }); ``` @@ -59,19 +58,19 @@ using result = await stub.getCounter(); When making requests via `fetch` or `R2.get()`, consume the entire response body, even if you are not asserting its content. For example: ```ts -test('check if file exists', async () => { - await env.R2.put('file', 'hello-world'); - const response = await env.R2.get('file'); +test("check if file exists", async () => { + await env.R2.put("file", "hello-world"); + const response = await env.R2.get("file"); expect(response).not.toBe(null); // Consume the response body even if you are not asserting it - await response.text() + await response.text(); }); ``` ### Missing properties on `ctx.exports` -The `ctx.exports` property provides access to the exports of the main (`SELF`) Worker. The Workers Vitest integration attempts to automatically infer these exports by statically analyzing the Worker source code using esbuild. However, complex build setups, such as those using virtual modules or wildcard re-exports that esbuild cannot follow, may result in missing properties on the `ctx.exports` object. +The `ctx.exports` property provides access to the exports of the main Worker. The Workers Vitest integration attempts to automatically infer these exports by statically analyzing the Worker source code using esbuild. However, complex build setups, such as those using virtual modules or wildcard re-exports that esbuild cannot follow, may result in missing properties on the `ctx.exports` object. For example, consider a Worker that re-exports an entrypoint from a virtual module using a wildcard export: @@ -85,19 +84,18 @@ In this case, any exports from `@virtual-module` (such as `MyEntrypoint`) cannot To work around this, add the `additionalExports` option to your Vitest configuration: ```ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.jsonc" }, - additionalExports: { - MyEntrypoint: "WorkerEntrypoint", - }, +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + additionalExports: { + MyEntrypoint: "WorkerEntrypoint", }, - }, - }, + }), + ], }); ``` @@ -107,10 +105,16 @@ The `additionalExports` option is a map where keys are the export names and valu If you encounter module resolution issues such as: `Error: Cannot use require() to import an ES Module` or `Error: No such module`, you can bundle these dependencies using the [deps.optimizer](https://vitest.dev/config/#deps-optimizer) option: -```tsx -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ +```ts +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + // ... + }), + ], test: { deps: { optimizer: { @@ -120,11 +124,6 @@ export default defineWorkersConfig({ }, }, }, - poolOptions: { - workers: { - // ... - }, - }, }, }); ``` @@ -138,41 +137,42 @@ To work around this, you can create a wrapper that uses Vite's SSR module loader ```ts // File: global-setup-wrapper.ts -import { createServer } from "vite" +import { createServer } from "vite"; // Import the actual global setup file with the correct setup -const mod = await viteImport("./global-setup.ts") +const mod = await viteImport("./global-setup.ts"); export default mod.default; // Helper to import the file with default node setup async function viteImport(file: string) { - const server = await createServer({ - root: import.meta.dirname, - configFile: false, - server: { middlewareMode: true, hmr: false, watch: null, ws: false }, - optimizeDeps: { noDiscovery: true }, - clearScreen: false, - }); - const mod = await server.ssrLoadModule(file); - await server.close(); - return mod; + const server = await createServer({ + root: import.meta.dirname, + configFile: false, + server: { middlewareMode: true, hmr: false, watch: null, ws: false }, + optimizeDeps: { noDiscovery: true }, + clearScreen: false, + }); + const mod = await server.ssrLoadModule(file); + await server.close(); + return mod; } ``` ```ts // File: vitest.config.ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersConfig({ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + // ... + }), + ], test: { // Replace the globalSetup with the wrapper file globalSetup: ["./global-setup-wrapper.ts"], - poolOptions: { - workers: { - // ... - }, - }, }, }); ``` diff --git a/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx b/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx index 22b4848d907..fcaf2dd62bc 100644 --- a/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx +++ b/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx @@ -30,29 +30,32 @@ First, you will need to uninstall the old environment and install the new pool. ```sh npm uninstall vitest-environment-miniflare -npm install --save-dev --save-exact vitest@~3.0.0 +npm install --save-dev vitest@^4.1.0 npm install --save-dev @cloudflare/vitest-pool-workers ``` ## Update your Vitest configuration file -After installing the Workers Vitest configuration, update your Vitest configuration file to use the pool instead. Most Miniflare configuration previously specified `environmentOptions` can be moved to `poolOptions.workers.miniflare` instead. Refer to [Miniflare's `WorkerOptions` interface](https://github.com/cloudflare/workers-sdk/blob/main/packages/miniflare/README.md#interface-workeroptions) for supported options and the [Miniflare version 2 to 3 migration guide](/workers/testing/miniflare/migrations/from-v2/) for more information. If you relied on configuration stored in a Wrangler file, set `wrangler.configPath` too. +After installing the Workers Vitest integration, update your Vitest configuration file to use the `cloudflareTest()` Vite plugin instead. Most Miniflare configuration previously specified in `environmentOptions` can be moved to the `miniflare` option in `cloudflareTest()`. Refer to [Miniflare's `WorkerOptions` interface](https://github.com/cloudflare/workers-sdk/blob/main/packages/miniflare/README.md#interface-workeroptions) for supported options and the [Miniflare version 2 to 3 migration guide](/workers/testing/miniflare/migrations/from-v2/) for more information. If you relied on configuration stored in a Wrangler file, set `wrangler.configPath` too. ```diff -+ import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; ++ import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; ++ import { defineConfig } from "vitest/config"; - export default defineWorkersConfig({ - test: { +- export default defineWorkersConfig({ +- test: { - environment: "miniflare", - environmentOptions: { ... }, -+ poolOptions: { -+ workers: { -+ miniflare: { ... }, -+ wrangler: { configPath: "./wrangler.toml" }, -+ }, -+ }, - }, - }); +- }, +- }); ++ export default defineConfig({ ++ plugins: [ ++ cloudflareTest({ ++ miniflare: { ... }, ++ wrangler: { configPath: "./wrangler.jsonc" }, ++ }), ++ ], ++ }); ``` ## Update your TypeScript configuration file @@ -74,11 +77,11 @@ If you are using TypeScript, update your `tsconfig.json` to include the correct ## Access bindings -To access [bindings](/workers/runtime-apis/bindings/) in your tests, use the `env` helper from the `cloudflare:test` module. +To access [bindings](/workers/runtime-apis/bindings/) in your tests, use the `env` helper from the `cloudflare:workers` module. ```diff import { it } from "vitest"; -+ import { env } from "cloudflare:test"; ++ import { env } from "cloudflare:workers"; it("does something", () => { - const env = getMiniflareBindings(); @@ -86,10 +89,10 @@ To access [bindings](/workers/runtime-apis/bindings/) in your tests, use the `en }); ``` -If you are using TypeScript, add an ambient `.d.ts` declaration file defining a `ProvidedEnv` `interface` in the `cloudflare:test` module to control the type of `env`: +If you are using TypeScript, add an ambient `.d.ts` declaration file defining a `ProvidedEnv` `interface` in the `cloudflare:workers` module to control the type of `env`: ```ts -declare module "cloudflare:test" { +declare module "cloudflare:workers" { interface ProvidedEnv { NAMESPACE: KVNamespace; } @@ -98,9 +101,9 @@ declare module "cloudflare:test" { } ``` -## Use isolated storage +## Storage isolation -Isolated storage is now enabled by default. You no longer need to include `setupMiniflareIsolatedStorage()` in your tests. +Storage isolation is per test file by default. You no longer need to include `setupMiniflareIsolatedStorage()` in your tests. ```diff - const describe = setupMiniflareIsolatedStorage(); @@ -126,33 +129,15 @@ The `new ExecutionContext()` constructor and `getMiniflareWaitUntil()` function ## Mock outbound requests -The `getMiniflareFetchMock()` function has been replaced with the new `fetchMock` helper from the `cloudflare:test` module. `fetchMock` has the same type as the return type of `getMiniflareFetchMock()`. There are a couple of differences between `fetchMock` and the previous return value of `getMiniflareFetchMock()`: - -- `fetchMock` is deactivated by default, whereas previously it would start activated. This deactivation prevents unnecessary buffering of request bodies if you are not using `fetchMock`. You will need to call `fetchMock.activate()` before calling `fetch()` to enable it. -- `fetchMock` is reset at the start of each test run, whereas previously, interceptors added in previous runs would apply to the current one. This ensures test runs are not affected by previous runs. - -```diff - import { beforeAll, afterAll } from "vitest"; -+ import { fetchMock } from "cloudflare:test"; - -- const fetchMock = getMiniflareFetchMock(); - beforeAll(() => { -+ fetchMock.activate(); - fetchMock.disableNetConnect(); - fetchMock - .get("https://example.com") - .intercept({ path: "/" }) - .reply(200, "data"); - }); - afterAll(() => fetchMock.assertNoPendingInterceptors()); -``` +The `getMiniflareFetchMock()` function is no longer available. To mock outbound `fetch()` requests, mock `globalThis.fetch` directly or use ecosystem libraries such as [MSW](https://mswjs.io/). Refer to the [request mocking example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts) for a complete example. ## Use Durable Object helpers The `getMiniflareDurableObjectStorage()`, `getMiniflareDurableObjectState()`, `getMiniflareDurableObjectInstance()`, and `runWithMiniflareDurableObjectGates()` functions have all been replaced with a single `runInDurableObject()` function from the `cloudflare:test` module. The `runInDurableObject()` function accepts a `DurableObjectStub` with a callback accepting the Durable Object and corresponding `DurableObjectState` as arguments. Consolidating these functions into a single function simplifies the API surface, and ensures instances are accessed with the correct request context and [gating behavior](https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/). Refer to the [Test APIs page](/workers/testing/vitest-integration/test-apis/) for more details. ```diff -+ import { env, runInDurableObject } from "cloudflare:test"; ++ import { env } from "cloudflare:workers"; ++ import { runInDurableObject } from "cloudflare:test"; it("does something", async () => { - const env = getMiniflareBindings(); @@ -184,7 +169,8 @@ The `getMiniflareDurableObjectStorage()`, `getMiniflareDurableObjectState()`, `g The `flushMiniflareDurableObjectAlarms()` function has been replaced with the `runDurableObjectAlarm()` function from the `cloudflare:test` module. The `runDurableObjectAlarm()` function accepts a single `DurableObjectStub` and returns a `Promise` that resolves to `true` if an alarm was scheduled and the `alarm()` handler was executed, or `false` otherwise. To "flush" multiple instances' alarms, call `runDurableObjectAlarm()` in a loop. ```diff -+ import { env, runDurableObjectAlarm } from "cloudflare:test"; ++ import { env } from "cloudflare:workers"; ++ import { runDurableObjectAlarm } from "cloudflare:test"; it("does something", async () => { - const env = getMiniflareBindings(); @@ -195,10 +181,11 @@ The `flushMiniflareDurableObjectAlarms()` function has been replaced with the `r }); ``` -Finally, the `getMiniflareDurableObjectIds()` function has been replaced with the `listDurableObjectIds()` function from the `cloudflare:test` module. The `listDurableObjectIds()` function now accepts a `DurableObjectNamespace` instance instead of a namespace `string` to provide stricter typing. Note the `listDurableObjectIds()` function now respects isolated storage. If enabled, IDs of objects created in other tests will not be returned. +Finally, the `getMiniflareDurableObjectIds()` function has been replaced with the `listDurableObjectIds()` function from the `cloudflare:test` module. The `listDurableObjectIds()` function now accepts a `DurableObjectNamespace` instance instead of a namespace `string` to provide stricter typing. Note the `listDurableObjectIds()` function respects storage isolation. IDs of objects created in other test files will not be returned. ```diff -+ import { env, listDurableObjectIds } from "cloudflare:test"; ++ import { env } from "cloudflare:workers"; ++ import { listDurableObjectIds } from "cloudflare:test"; it("does something", async () => { - const ids = await getMiniflareDurableObjectIds("OBJECT"); diff --git a/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-unstable-dev.mdx b/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-unstable-dev.mdx index 234a6c00e9b..fc720c97a87 100644 --- a/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-unstable-dev.mdx +++ b/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-unstable-dev.mdx @@ -27,14 +27,14 @@ it("dispatches fetch event", () => { }) ``` -With the Workers Vitest integration, you can accomplish the same goal using `SELF` from `cloudflare:test`. `SELF` is a [service binding](/workers/runtime-apis/bindings/service-bindings/) to the default export defined by the `main` option in your [Wrangler configuration file](/workers/wrangler/configuration/). This `main` Worker runs in the same isolate as tests so any global mocks will apply to it too. +With the Workers Vitest integration, you can accomplish the same goal using `exports` from `cloudflare:workers`. `exports.default` refers to the default export defined by the `main` option in your [Wrangler configuration file](/workers/wrangler/configuration/). This `main` Worker runs in the same isolate as tests so any global mocks will apply to it too. ```js -import { SELF } from "cloudflare:test"; +import { exports } from "cloudflare:workers"; import "../src/"; // Currently required to automatically rerun tests when `main` changes it("dispatches fetch event", async () => { - const response = await SELF.fetch("http://example.com"); + const response = await exports.default.fetch("http://example.com"); ... }); ``` @@ -55,19 +55,19 @@ await unstable_dev("src/index.ts", { With the Workers Vitest integration, you can now set this reference to a [Wrangler configuration file](/workers/wrangler/configuration/) in `vitest.config.js` for all of your tests: -```js null {5-7} -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { - configPath: "wrangler.toml", - }, - }, - }, - }, +```js {3-5} +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "wrangler.jsonc", + }, + }), + ], }); ---- ``` ## Test 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 fcb83721f99..aeec8d82f28 100644 --- a/src/content/docs/workers/testing/vitest-integration/test-apis.mdx +++ b/src/content/docs/workers/testing/vitest-integration/test-apis.mdx @@ -4,433 +4,424 @@ pcx_content_type: reference sidebar: order: 5 head: [] -description: Runtime helpers for writing tests, exported from the `cloudflare:test` module. - +description: Runtime helpers for writing tests with the Workers Vitest integration. --- -The Workers Vitest integration provides runtime helpers for writing tests in the `cloudflare:test` module. The `cloudflare:test` module is provided by the `@cloudflare/vitest-pool-workers` package, but can only be imported from test files that execute in the Workers runtime. - -## `cloudflare:test` module definition - +The Workers Vitest integration provides runtime helpers for writing tests. Bindings and exports are available from the `cloudflare:workers` module. Additional test utilities are available from the `cloudflare:test` module, provided by the `@cloudflare/vitest-pool-workers` package. These modules can only be imported from test files that execute in the Workers runtime. +## `cloudflare:workers` exports -* env: import("cloudflare:test").ProvidedEnv +- env: import("cloudflare:workers").ProvidedEnv - Exposes the + [`env` object](/workers/runtime-apis/handlers/fetch/#parameters) for use as + the second argument passed to ES modules format exported handlers. This + provides access to [bindings](/workers/runtime-apis/bindings/) that you have + defined in your [Vitest configuration + file](/workers/testing/vitest-integration/configuration/). - * Exposes the [`env` object](/workers/runtime-apis/handlers/fetch/#parameters) for use as the second argument passed to ES modules format exported handlers. This provides access to [bindings](/workers/runtime-apis/bindings/) that you have defined in your [Vitest configuration file](/workers/testing/vitest-integration/configuration/). - -
- - ```js - import { env } from "cloudflare:test"; +
- it("uses binding", async () => { - await env.KV_NAMESPACE.put("key", "value"); - expect(await env.KV_NAMESPACE.get("key")).toBe("value"); - }); - ``` - - To configure the type of this value, use an ambient module type: - - ```ts - declare module "cloudflare:test" { - interface ProvidedEnv { - KV_NAMESPACE: KVNamespace; - } - // ...or if you have an existing `Env` type... - interface ProvidedEnv extends Env {} - } - ``` + ```js + import { env } from "cloudflare:workers"; -* SELF: Fetcher + it("uses binding", async () => { + await env.KV_NAMESPACE.put("key", "value"); + expect(await env.KV_NAMESPACE.get("key")).toBe("value"); + }); + ``` - * [Service binding](/workers/runtime-apis/bindings/service-bindings/) to the default export defined in the `main` Worker. Use this to write integration tests against your Worker. The `main` Worker runs in the same isolate/context as tests so any global mocks will apply to it too. + To configure the type of this value, use an ambient module type: -
+ ```ts + declare module "cloudflare:workers" { + interface ProvidedEnv { + KV_NAMESPACE: KVNamespace; + } + // ...or if you have an existing `Env` type... + interface ProvidedEnv extends Env {} + } + ``` - ```js - import { SELF } from "cloudflare:test"; +- exports: object - Provides access to the `main` Worker's exports. + Use `exports.default` to call the default export's handlers for integration + tests. The `main` Worker runs in the same isolate/context as tests so any + global mocks will apply to it too. - it("dispatches fetch event", async () => { - const response = await SELF.fetch("https://example.com"); - expect(await response.text()).toMatchInlineSnapshot(...); - }); - ``` + `exports.default.fetch()` does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). -* fetchMock: import("undici").MockAgent +
- * Declarative interface for mocking outbound `fetch()` requests. Deactivated by default and reset before running each test file. Refer to [`undici`'s `MockAgent` documentation](https://undici.nodejs.org/#/docs/api/MockAgent) for more information. Note this only mocks `fetch()` requests for the current test runner Worker. Auxiliary Workers should mock `fetch()`es using the Miniflare `fetchMock`/`outboundService` options. Refer to [Configuration](/workers/testing/vitest-integration/configuration/#workerspooloptions) for more information. + ```js + import { exports } from "cloudflare:workers"; -
+ it("dispatches fetch event", async () => { + const response = await exports.default.fetch("https://example.com"); + expect(await response.text()).toMatchInlineSnapshot(...); + }); + ``` - ```js - import { fetchMock } from "cloudflare:test"; - import { beforeAll, afterEach, it, expect } from "vitest"; - - beforeAll(() => { - // Enable outbound request mocking... - fetchMock.activate(); - // ...and throw errors if an outbound request isn't mocked - fetchMock.disableNetConnect(); - }); - // Ensure we matched every mock we defined - afterEach(() => fetchMock.assertNoPendingInterceptors()); - - it("mocks requests", async () => { - // Mock the first request to `https://example.com` - fetchMock - .get("https://example.com") - .intercept({ path: "/" }) - .reply(200, "body"); - - const response = await fetch("https://example.com/"); - expect(await response.text()).toBe("body"); - }); - ``` +## `cloudflare:test` module definition +### Mocking outbound requests +To mock outbound `fetch()` requests, mock `globalThis.fetch` directly or use ecosystem libraries such as [MSW](https://mswjs.io/). Refer to the [request mocking example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts) for a complete example. ### Events +- createExecutionContext(): ExecutionContext - Creates an instance + of the [`context` object](/workers/runtime-apis/handlers/fetch/#parameters) + for use as the third argument to ES modules format exported handlers. + +- waitOnExecutionContext(ctx:ExecutionContext): Promise\ +- Use this to wait for all Promises passed to `ctx.waitUntil()` to settle, before running test assertions on any side effects. Only accepts instances of `ExecutionContext` returned by `createExecutionContext()`. + +
+ + ```ts + import { env } from "cloudflare:workers"; + import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; + import { it, expect } from "vitest"; + import worker from "./index.mjs"; + + it("calls fetch handler", async () => { + const request = new Request("https://example.com"); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot(...); + }); + ``` + +- createScheduledController(options?:FetcherScheduledOptions): + ScheduledController - Creates an instance of `ScheduledController` for use as + the first argument to modules-format + [`scheduled()`](/workers/runtime-apis/handlers/scheduled/) exported handlers. + +
+ + ```ts + import { env } from "cloudflare:workers"; + import { + createScheduledController, + createExecutionContext, + waitOnExecutionContext, + } from "cloudflare:test"; + import { it, expect } from "vitest"; + import worker from "./index.mjs"; + + it("calls scheduled handler", async () => { + const ctrl = createScheduledController({ + scheduledTime: new Date(1000), + cron: "30 * * * *", + }); + const ctx = createExecutionContext(); + await worker.scheduled(ctrl, env, ctx); + await waitOnExecutionContext(ctx); + }); + ``` + +- + createMessageBatch(queueName:string, messages:ServiceBindingQueueMessage\[]) + + : MessageBatch - Creates an instance of `MessageBatch` for use as the first + argument to modules-format + [`queue()`](/queues/configuration/javascript-apis/#consumer) exported + handlers. + +- getQueueResult(batch:MessageBatch, ctx:ExecutionContext): Promise\ +- Gets the acknowledged/retry state of messages in the `MessageBatch`, and waits for all `ExecutionContext#waitUntil()`ed `Promise`s to settle. Only accepts instances of `MessageBatch` returned by `createMessageBatch()`, and instances of `ExecutionContext` returned by `createExecutionContext()`. + +
+ + ```ts + import { env } from "cloudflare:workers"; + import { + createMessageBatch, + createExecutionContext, + getQueueResult, + } from "cloudflare:test"; + import { it, expect } from "vitest"; + import worker from "./index.mjs"; + + it("calls queue handler", async () => { + const batch = createMessageBatch("my-queue", [ + { + id: "message-1", + timestamp: new Date(1000), + body: "body-1", + }, + ]); + const ctx = createExecutionContext(); + await worker.queue(batch, env, ctx); + const result = await getQueueResult(batch, ctx); + expect(result.ackAll).toBe(false); + expect(result.retryBatch).toMatchObject({ retry: false }); + expect(result.explicitAcks).toStrictEqual(["message-1"]); + expect(result.retryMessages).toStrictEqual([]); + }); + ``` +### Durable Objects -* createExecutionContext(): ExecutionContext - - * Creates an instance of the [`context` object](/workers/runtime-apis/handlers/fetch/#parameters) for use as the third argument to ES modules format exported handlers. - -* waitOnExecutionContext(ctx:ExecutionContext): Promise\ +- runInDurableObject\(stub:DurableObjectStub, callback:(instance: O, state: DurableObjectState) => R | Promise\): Promise\ +- Runs the provided `callback` inside the Durable Object that corresponds to the provided `stub`. + +
+ + This temporarily replaces your Durable Object's `fetch()` handler with `callback`, then sends a request to it, returning the result. This can be used to call/spy-on Durable Object methods or seed/get persisted data. Note this can only be used with `stub`s pointing to Durable Objects defined in the `main` Worker. + +
+ + ```ts + export class Counter { + constructor(readonly state: DurableObjectState) {} + + async fetch(request: Request): Promise { + let count = (await this.state.storage.get("count")) ?? 0; + void this.state.storage.put("count", ++count); + return new Response(count.toString()); + } + } + ``` + + ```ts + import { env } from "cloudflare:workers"; + import { runInDurableObject } from "cloudflare:test"; + import { it, expect } from "vitest"; + import { Counter } from "./index.ts"; + + it("increments count", async () => { + const id = env.COUNTER.newUniqueId(); + const stub = env.COUNTER.get(id); + let response = await stub.fetch("https://example.com"); + expect(await response.text()).toBe("1"); + + response = await runInDurableObject( + stub, + async (instance: Counter, state) => { + expect(instance).toBeInstanceOf(Counter); + expect(await state.storage.get("count")).toBe(1); + + const request = new Request("https://example.com"); + return instance.fetch(request); + }, + ); + expect(await response.text()).toBe("2"); + }); + ``` + +- runDurableObjectAlarm(stub:DurableObjectStub): Promise\ +- 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. + +- listDurableObjectIds(namespace:DurableObjectNamespace): Promise\ +- Gets the IDs of all objects that have been created in the `namespace`. Storage isolation is per test file, meaning objects created in a different test file will not be returned. + +
+ + ```ts + import { env } from "cloudflare:workers"; + import { listDurableObjectIds } from "cloudflare:test"; + import { it, expect } from "vitest"; + + it("increments count", async () => { + const id = env.COUNTER.newUniqueId(); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com"); + expect(await response.text()).toBe("1"); + + const ids = await listDurableObjectIds(env.COUNTER); + expect(ids.length).toBe(1); + expect(ids[0].equals(id)).toBe(true); + }); + ``` - * Use this to wait for all Promises passed to `ctx.waitUntil()` to settle, before running test assertions on any side effects. Only accepts instances of `ExecutionContext` returned by `createExecutionContext()`. +### D1 -
+- applyD1Migrations(db:D1Database, migrations:D1Migration\[], migrationTableName?:string): Promise\ +- Applies all un-applied [D1 migrations](/d1/reference/migrations/) stored in the `migrations` array to database `db`, recording migrations state in the `migrationsTableName` table. `migrationsTableName` defaults to `d1_migrations`. Call the [`readD1Migrations()`](/workers/testing/vitest-integration/configuration/#readd1migrationsmigrationspath) function from the `@cloudflare/vitest-pool-workers/config` package inside Node.js to get the `migrations` array. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations. - ```ts - import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; - import { it, expect } from "vitest"; - import worker from "./index.mjs"; - - it("calls fetch handler", async () => { - const request = new Request("https://example.com"); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(...); - }); - ``` +### Workflows -* createScheduledController(options?:FetcherScheduledOptions): ScheduledController +:::caution[Workflows test isolation] - * Creates an instance of `ScheduledController` for use as the first argument to modules-format [`scheduled()`](/workers/runtime-apis/handlers/scheduled/) exported handlers. +To ensure proper test isolation in Workflows, introspectors should be disposed at the end of each test. +This is accomplished by either: -
+- Using an `await using` statement on the introspector. +- Explicitly calling the introspector `dispose()` method. - ```ts - import { env, createScheduledController, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; - import { it, expect } from "vitest"; - import worker from "./index.mjs"; - - it("calls scheduled handler", async () => { - const ctrl = createScheduledController({ - scheduledTime: new Date(1000), - cron: "30 * * * *" - }); - const ctx = createExecutionContext(); - await worker.scheduled(ctrl, env, ctx); - await waitOnExecutionContext(ctx); - }); - ``` +::: -* createMessageBatch(queueName:string, messages:ServiceBindingQueueMessage\[]): MessageBatch +:::note[Version] - * Creates an instance of `MessageBatch` for use as the first argument to modules-format [`queue()`](/queues/configuration/javascript-apis/#consumer) exported handlers. +Available in `@cloudflare/vitest-pool-workers` version **0.9.0**! -* getQueueResult(batch:MessageBatch, ctx:ExecutionContext): Promise\ +::: - * Gets the acknowledged/retry state of messages in the `MessageBatch`, and waits for all `ExecutionContext#waitUntil()`ed `Promise`s to settle. Only accepts instances of `MessageBatch` returned by `createMessageBatch()`, and instances of `ExecutionContext` returned by `createExecutionContext()`. +- `introspectWorkflowInstance(workflow: Workflow, instanceId: string)`: Promise\ + - Creates an **introspector** for a specific Workflow instance, used to **modify** its behavior, **await** outcomes, and **clear** its state during tests. This is the primary entry point for testing individual Workflow instances with a known ID. -
+
```ts - import { env, createMessageBatch, createExecutionContext, getQueueResult } from "cloudflare:test"; - import { it, expect } from "vitest"; - import worker from "./index.mjs"; - - it("calls queue handler", async () => { - const batch = createMessageBatch("my-queue", [ - { - id: "message-1", - timestamp: new Date(1000), - body: "body-1" - } - ]); - const ctx = createExecutionContext(); - await worker.queue(batch, env, ctx); - const result = await getQueueResult(batch, ctx); - expect(result.ackAll).toBe(false); - expect(result.retryBatch).toMatchObject({ retry: false }); - expect(result.explicitAcks).toStrictEqual(["message-1"]); - expect(result.retryMessages).toStrictEqual([]); + import { env } from "cloudflare:workers"; + import { introspectWorkflowInstance } from "cloudflare:test"; + + it("should disable all sleeps, mock an event and complete", async () => { + // 1. CONFIGURATION + await using instance = await introspectWorkflowInstance( + env.MY_WORKFLOW, + "123456", + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockEvent({ + type: "user-approval", + payload: { approved: true, approverId: "user-123" }, + }); + }); + + // 2. EXECUTION + await env.MY_WORKFLOW.create({ id: "123456" }); + + // 3. ASSERTION + await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); + const output = await instance.getOutput(); + expect(output).toEqual({ success: true }); + + // 4. DISPOSE: is implicit and automatic here. }); ``` + - The returned `WorkflowInstanceIntrospector` object has the following methods: + - `modify(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to the Workflow instance's behavior. + - `waitForStepResult(step: { name: string; index?: number }): Promise`: Waits for a specific step to complete and returns a result. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to target a specific occurrence. + - `waitForStatus(status: InstanceStatus["status"]): Promise`: Waits for the Workflow instance to reach a specific [status](/workflows/build/workers-api/#instancestatus) (e.g., 'running', 'complete'). + - `getOutput(): Promise`: Returns the output value of the successful completed Workflow instance. + - `getError(): Promise<{name: string, message: string}>`: Returns the error information of the errored Workflow instance. The error information follows the form `{ name: string; message: string }`. + - `dispose(): Promise`: Disposes the Workflow instance, which is crucial for test isolation. If this function is not called and `await using` is not used, the instance's state will persist across subsequent tests. For example, an instance that becomes completed in one test will already be completed at the start of the next. + - `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. +- `introspectWorkflow(workflow: Workflow)`: Promise\ + - Creates an **introspector** for a Workflow where instance IDs are unknown beforehand. This allows for defining modifications that will apply to **all subsequently created instances**. -### Durable Objects - - - -* runInDurableObject\(stub:DurableObjectStub, callback:(instance: O, state: DurableObjectState) => R | Promise\): Promise\ - - * Runs the provided `callback` inside the Durable Object that corresponds to the provided `stub`. - -
- - This temporarily replaces your Durable Object's `fetch()` handler with `callback`, then sends a request to it, returning the result. This can be used to call/spy-on Durable Object methods or seed/get persisted data. Note this can only be used with `stub`s pointing to Durable Objects defined in the `main` Worker. - -
+
```ts - export class Counter { - constructor(readonly state: DurableObjectState) {} - - async fetch(request: Request): Promise { - let count = (await this.state.storage.get("count")) ?? 0; - void this.state.storage.put("count", ++count); - return new Response(count.toString()); + import { env, exports } from "cloudflare:workers"; + import { introspectWorkflow } from "cloudflare:test"; + + it("should disable all sleeps, mock an event and complete", async () => { + // 1. CONFIGURATION + await using introspector = await introspectWorkflow(env.MY_WORKFLOW); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockEvent({ + type: "user-approval", + payload: { approved: true, approverId: "user-123" }, + }); + }); + + // 2. EXECUTION + await env.MY_WORKFLOW.create(); + + // 3. ASSERTION + const instances = introspector.get(); + for (const instance of instances) { + await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); + const output = await instance.getOutput(); + expect(output).toEqual({ success: true }); } - } - ``` - ```ts - import { env, runInDurableObject } from "cloudflare:test"; - import { it, expect } from "vitest"; - import { Counter } from "./index.ts"; - - it("increments count", async () => { - const id = env.COUNTER.newUniqueId(); - const stub = env.COUNTER.get(id); - let response = await stub.fetch("https://example.com"); - expect(await response.text()).toBe("1"); - - response = await runInDurableObject(stub, async (instance: Counter, state) => { - expect(instance).toBeInstanceOf(Counter); - expect(await state.storage.get("count")).toBe(1); - - const request = new Request("https://example.com"); - return instance.fetch(request); - }); - expect(await response.text()).toBe("2"); + // 4. DISPOSE: is implicit and automatic here. }); ``` -* runDurableObjectAlarm(stub:DurableObjectStub): Promise\ - - * 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. - -* listDurableObjectIds(namespace:DurableObjectNamespace): Promise\ + The workflow instance doesn't have to be created directly inside the test. The introspector will capture **all** instances created after it is initialized. For example, you could trigger the creation of **one or multiple** instances via a single `fetch` event to your Worker: - * Gets the IDs of all objects that have been created in the `namespace`. Respects `isolatedStorage` if enabled, meaning objects created in a different test will not be returned. - -
- - ```ts - import { env, listDurableObjectIds } from "cloudflare:test"; - import { it, expect } from "vitest"; - - it("increments count", async () => { - const id = env.COUNTER.newUniqueId(); - const stub = env.COUNTER.get(id); - const response = await stub.fetch("https://example.com"); - expect(await response.text()).toBe("1"); - - const ids = await listDurableObjectIds(env.COUNTER); - expect(ids.length).toBe(1); - expect(ids[0].equals(id)).toBe(true); - }); + ```js + // This also works for the EXECUTION phase: + await exports.default.fetch("https://example.com/trigger-workflows"); ``` - - -### D1 - - - -* applyD1Migrations(db:D1Database, migrations:D1Migration\[], migrationTableName?:string): Promise\ - - * Applies all un-applied [D1 migrations](/d1/reference/migrations/) stored in the `migrations` array to database `db`, recording migrations state in the `migrationsTableName` table. `migrationsTableName` defaults to `d1_migrations`. Call the [`readD1Migrations()`](/workers/testing/vitest-integration/configuration/#readd1migrationsmigrationspath) function from the `@cloudflare/vitest-pool-workers/config` package inside Node.js to get the `migrations` array. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations. - - -### Workflows - - - -:::caution[Workflows with `isolatedStorage`] - -To ensure proper test isolation in Workflows with isolated storage, introspectors should be disposed at the end of each test. -This is accomplished by either: -* Using an `await using` statement on the introspector. -* Explicitly calling the introspector `dispose()` method. - -::: - -:::note[Version] - -Available in `@cloudflare/vitest-pool-workers` version **0.9.0**! - -::: - -* `introspectWorkflowInstance(workflow: Workflow, instanceId: string)`: Promise\ - * Creates an **introspector** for a specific Workflow instance, used to **modify** its behavior, **await** outcomes, and **clear** its state during tests. This is the primary entry point for testing individual Workflow instances with a known ID. -
- - ```ts - import { env, introspectWorkflowInstance } from "cloudflare:test"; - - it("should disable all sleeps, mock an event and complete", async () => { - // 1. CONFIGURATION - await using instance = await introspectWorkflowInstance(env.MY_WORKFLOW, "123456"); + - The returned `WorkflowIntrospector` object has the following methods: + - `modifyAll(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to all Workflow instances created after calling `introspectWorkflow`. + - `get(): Promise`: Returns all `WorkflowInstanceIntrospector` objects from instances created after `introspectWorkflow` was called. + - `dispose(): Promise`: Disposes the Workflow introspector. All `WorkflowInstanceIntrospector` from created instances will also be disposed. This is crucial to prevent modifications and captured instances from leaking between tests. After calling this method, the `WorkflowIntrospector` should not be reused. + - `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. + +- `WorkflowInstanceModifier` + - This object is provided to the `modify` and `modifyAll` callbacks to mock or alter the behavior of a Workflow instance's steps, events, and sleeps. + - `disableSleeps(steps?: { name: string; index?: number }[])`: Disables sleeps, causing `step.sleep()` and `step.sleepUntil()` to resolve immediately. If `steps` is omitted, all sleeps are disabled. + - `mockStepResult(step: { name: string; index?: number }, stepResult: unknown)`: Mocks the result of a `step.do()`, causing it to return the specified value instantly without executing the step's implementation. + - `mockStepError(step: { name: string; index?: number }, error: Error, times?: number)`: Forces a `step.do()` to throw an error, simulating a failure. `times` is an optional number that sets how many times the step should error. If `times` is omitted, the step will error on every attempt, making the Workflow instance fail. + - `forceStepTimeout(step: { name: string; index?: number }, times?: number)`: Forces a `step.do()` to fail by timing out immediately. `times` is an optional number that sets how many times the step should timeout. If `times` is omitted, the step will timeout on every attempt, making the Workflow instance fail. + - `mockEvent(event: { type: string; payload: unknown })`: Sends a mock event to the Workflow instance, causing a `step.waitForEvent()` to resolve with the provided payload. `type` must match the `waitForEvent` type. + - `forceEventTimeout(step: { name: string; index?: number })`: Forces a `step.waitForEvent()` to time out instantly, causing the step to fail. + +
+ ```ts import {env} from "cloudflare:workers"; import + {introspectWorkflowInstance} from "cloudflare:test"; + + // This example showcases explicit disposal + it("should apply all modifier functions", async () => { + // 1. CONFIGURATION + const instance = await introspectWorkflowInstance(env.COMPLEX_WORKFLOW, "123456"); + + try { + // Modify instance behavior await instance.modify(async (m) => { + // Disables all sleeps to make the test run instantly await m.disableSleeps(); + + // Mocks the successful result of a data-fetching step + await m.mockStepResult( + { name: "get-order-details" }, + { orderId: "abc-123", amount: 99.99 } + ); + + // Mocks an incoming event to satisfy a `step.waitForEvent()` await m.mockEvent({ type: "user-approval", payload: { approved: true, approverId: "user-123" }, }); + + // Forces a step to fail once with a specific error to test retry logic + await m.mockStepError( + { name: "process-payment" }, + new Error("Payment gateway timeout"), + 1 // Fail only the first time + ); + + // Forces a `step.do()` to time out immediately + await m.forceStepTimeout({ name: "notify-shipping-partner" }); + + // Forces a `step.waitForEvent()` to time out + await m.forceEventTimeout({ name: "wait-for-fraud-check" }); }); // 2. EXECUTION - await env.MY_WORKFLOW.create({ id: "123456" }); + await env.COMPLEX_WORKFLOW.create({ id: "123456" }); // 3. ASSERTION - await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); - const output = await instance.getOutput(); - expect(output).toEqual({ success: true }); - - // 4. DISPOSE: is implicit and automatic here. - }); - ``` - * The returned `WorkflowInstanceIntrospector` object has the following methods: - * `modify(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to the Workflow instance's behavior. - * `waitForStepResult(step: { name: string; index?: number }): Promise`: Waits for a specific step to complete and returns a result. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to target a specific occurrence. - * `waitForStatus(status: InstanceStatus["status"]): Promise`: Waits for the Workflow instance to reach a specific [status](/workflows/build/workers-api/#instancestatus) (e.g., 'running', 'complete'). - * `getOutput(): Promise`: Returns the output value of the successful completed Workflow instance. - * `getError(): Promise<{name: string, message: string}>`: Returns the error information of the errored Workflow instance. The error information follows the form `{ name: string; message: string }`. - * `dispose(): Promise`: Disposes the Workflow instance, which is crucial for test isolation. If this function isn't called and `await using` is not used, isolated storage will fail and the instance's state will persist across subsequent tests. For example, an instance that becomes completed in one test will already be completed at the start of the next. - * `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. - -* `introspectWorkflow(workflow: Workflow)`: Promise\ - * Creates an **introspector** for a Workflow where instance IDs are unknown beforehand. This allows for defining modifications that will apply to **all subsequently created instances**. -
- - ```ts - import { env, introspectWorkflow, SELF } from "cloudflare:test"; - - it("should disable all sleeps, mock an event and complete", async () => { - // 1. CONFIGURATION - await using introspector = await introspectWorkflow(env.MY_WORKFLOW); - await introspector.modifyAll(async (m) => { - await m.disableSleeps(); - await m.mockEvent({ - type: "user-approval", - payload: { approved: true, approverId: "user-123" }, - }); + expect(await instance.waitForStepResult({ name: "get-order-details" })).toEqual({ + orderId: "abc-123", + amount: 99.99, }); + // Given the forced timeouts, the workflow will end in an errored state + await expect(instance.waitForStatus("errored")).resolves.not.toThrow(); - // 2. EXECUTION - await env.MY_WORKFLOW.create(); + const error = await instance.getError(); + expect(error.name).toEqual("Error"); + expect(error.message).toContain("Execution timed out"); - // 3. ASSERTION - const instances = introspector.get(); - for(const instance of instances) { - await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); - const output = await instance.getOutput(); - expect(output).toEqual({ success: true }); - } - - // 4. DISPOSE: is implicit and automatic here. - }); - ``` - The workflow instance doesn't have to be created directly inside the test. The introspector will capture **all** instances created after it is initialized. For example, you could trigger the creation of **one or multiple** instances via a single `fetch` event to your Worker: - ```js - // This also works for the EXECUTION phase: - await SELF.fetch("https://example.com/trigger-workflows"); - ``` - - * The returned `WorkflowIntrospector` object has the following methods: - * `modifyAll(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to all Workflow instances created after calling `introspectWorkflow`. - * `get(): Promise`: Returns all `WorkflowInstanceIntrospector` objects from instances created after `introspectWorkflow` was called. - * `dispose(): Promise`: Disposes the Workflow introspector. All `WorkflowInstanceIntrospector` from created instances will also be disposed. This is crucial to prevent modifications and captured instances from leaking between tests. After calling this method, the `WorkflowIntrospector` should not be reused. - * `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. - -* `WorkflowInstanceModifier` - * This object is provided to the `modify` and `modifyAll` callbacks to mock or alter the behavior of a Workflow instance's steps, events, and sleeps. - * `disableSleeps(steps?: { name: string; index?: number }[])`: Disables sleeps, causing `step.sleep()` and `step.sleepUntil()` to resolve immediately. If `steps` is omitted, all sleeps are disabled. - * `mockStepResult(step: { name: string; index?: number }, stepResult: unknown)`: Mocks the result of a `step.do()`, causing it to return the specified value instantly without executing the step's implementation. - * `mockStepError(step: { name: string; index?: number }, error: Error, times?: number)`: Forces a `step.do()` to throw an error, simulating a failure. `times` is an optional number that sets how many times the step should error. If `times` is omitted, the step will error on every attempt, making the Workflow instance fail. - * `forceStepTimeout(step: { name: string; index?: number }, times?: number)`: Forces a `step.do()` to fail by timing out immediately. `times` is an optional number that sets how many times the step should timeout. If `times` is omitted, the step will timeout on every attempt, making the Workflow instance fail. - * `mockEvent(event: { type: string; payload: unknown })`: Sends a mock event to the Workflow instance, causing a `step.waitForEvent()` to resolve with the provided payload. `type` must match the `waitForEvent` type. - * `forceEventTimeout(step: { name: string; index?: number })`: Forces a `step.waitForEvent()` to time out instantly, causing the step to fail. - -
- ```ts - import { env, introspectWorkflowInstance } from "cloudflare:test"; - - // This example showcases explicit disposal - it("should apply all modifier functions", async () => { - // 1. CONFIGURATION - const instance = await introspectWorkflowInstance(env.COMPLEX_WORKFLOW, "123456"); - - try { - // Modify instance behavior - await instance.modify(async (m) => { - // Disables all sleeps to make the test run instantly - await m.disableSleeps(); - - // Mocks the successful result of a data-fetching step - await m.mockStepResult( - { name: "get-order-details" }, - { orderId: "abc-123", amount: 99.99 } - ); - - // Mocks an incoming event to satisfy a `step.waitForEvent()` - await m.mockEvent({ - type: "user-approval", - payload: { approved: true, approverId: "user-123" }, - }); - - // Forces a step to fail once with a specific error to test retry logic - await m.mockStepError( - { name: "process-payment" }, - new Error("Payment gateway timeout"), - 1 // Fail only the first time - ); - - // Forces a `step.do()` to time out immediately - await m.forceStepTimeout({ name: "notify-shipping-partner" }); - - // Forces a `step.waitForEvent()` to time out - await m.forceEventTimeout({ name: "wait-for-fraud-check" }); - }); + } catch { + // 4. DISPOSE + await instance.dispose(); + } - // 2. EXECUTION - await env.COMPLEX_WORKFLOW.create({ id: "123456" }); + }); - // 3. ASSERTION - expect(await instance.waitForStepResult({ name: "get-order-details" })).toEqual({ - orderId: "abc-123", - amount: 99.99, - }); - // Given the forced timeouts, the workflow will end in an errored state - await expect(instance.waitForStatus("errored")).resolves.not.toThrow(); - - const error = await instance.getError(); - expect(error.name).toEqual("Error"); - expect(error.message).toContain("Execution timed out"); - - } catch { - // 4. DISPOSE - await instance.dispose(); - } - }); - ``` - - When targeting a step, use its `name`. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to specify the occurrence. - \ No newline at end of file + ``` + + When targeting a step, use its `name`. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to specify the occurrence. + ``` diff --git a/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx b/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx index dc52c1fa599..b84586603e8 100644 --- a/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx +++ b/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx @@ -26,56 +26,47 @@ First, make sure that: - Vitest and `@cloudflare/vitest-pool-workers` are installed in your project as dev dependencies - :::note - - Currently, the `@cloudflare/vitest-pool-workers` package _only_ works with Vitest 2.0.x - 3.2.x. - - ::: - ## Define Vitest configuration -In your `vitest.config.ts` file, use `defineWorkersConfig` to configure the Workers Vitest integration. +In your `vitest.config.ts` file, use the `cloudflareTest()` Vite plugin to configure the Workers Vitest integration. You can use your Worker configuration from your [Wrangler config file](/workers/wrangler/configuration/) by specifying it with `wrangler.configPath`. -```ts title = vitest.config.ts -import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; +```ts title="vitest.config.ts" +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.jsonc" }, - }, - }, - }, +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], }); ``` -You can also override or define additional configuration using the `miniflare` key. This takes precedence over values set in via your Wrangler config. +You can also override or define additional configuration using the `miniflare` key. This takes precedence over values set via your Wrangler config. -For example, this configuration would add a KV namespace `TEST_NAMESPACE` that was only accessed and modified in tests. +For example, this configuration would add a KV namespace `TEST_NAMESPACE` that is only accessed and modified in tests. - ```js null {6-8} - export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.jsonc" }, - miniflare: { - kvNamespaces: ["TEST_NAMESPACE"], - }, - }, - }, - }, - }); - ``` +```ts title="vitest.config.ts" {5-7} +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { + kvNamespaces: ["TEST_NAMESPACE"], + }, + }), + ], +}); +``` - For a full list of available Miniflare options, refer to the [Miniflare `WorkersOptions` API documentation](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare#interface-workeroptions). +For a full list of available Miniflare options, refer to the [Miniflare `WorkersOptions` API documentation](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare#interface-workeroptions). For a full list of available configuration options, refer to [Configuration](/workers/testing/vitest-integration/configuration/). @@ -109,8 +100,8 @@ You should also add the output of `wrangler types` to the `include` array so tha You also need to define the type of the `env` object that is provided to your tests. Create an `env.d.ts` file in your tests folder, and declare the `ProvidedEnv` interface by extending the `Env` interface that is generated by `wrangler types`. ```ts title="test/env.d.ts" -declare module "cloudflare:test" { - // ProvidedEnv controls the type of `import("cloudflare:test").env` +declare module "cloudflare:workers" { + // ProvidedEnv controls the type of `import("cloudflare:workers").env` interface ProvidedEnv extends Env {} } ``` @@ -141,11 +132,8 @@ By importing the Worker we can write a unit test for its `fetch` handler. ```ts - import { - env, - createExecutionContext, - waitOnExecutionContext, - } from "cloudflare:test"; + import { env } from "cloudflare:workers"; + import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; import { describe, it, expect } from "vitest"; // Import your worker so you can unit test it import worker from "../src"; @@ -173,16 +161,16 @@ By importing the Worker we can write a unit test for its `fetch` handler. ### Integration tests -You can use the SELF fetcher provided by the `cloudflare:test` to write an integration test. This is a service binding to the default export defined in the main Worker. +You can use the `exports` object from `cloudflare:workers` to write an integration test. `exports.default` refers to the default export defined in the main Worker. ```ts - import { SELF } from "cloudflare:test"; + import { exports } from "cloudflare:workers"; import { describe, it, expect } from "vitest"; describe("Hello World worker", () => { it("responds with not found and proper status for /404", async () => { - const response = await SELF.fetch("http://example.com/404"); + const response = await exports.default.fetch("http://example.com/404"); expect(response.status).toBe(404); expect(await response.text()).toBe("Not found"); }); @@ -192,8 +180,9 @@ You can use the SELF fetcher provided by the `cloudflare:test` to write an integ -When using `SELF` for integration tests, your Worker code runs in the same context as the test runner. This means you can use global mocks to control your Worker, but also means your Worker uses the subtly different module resolution behavior provided by Vite. -Usually this is not a problem, but to run your Worker in a fresh environment that is as close to production as possible, you can use an auxiliary Worker. Refer to [this example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/vitest.config.ts) for how to set up integration tests using auxiliary Workers. However, using auxiliary Workers comes with [limitations](/workers/testing/vitest-integration/configuration/#workerspooloptions) that you should be aware of. +When using `exports.default.fetch()` for integration tests, your Worker code runs in the same context as the test runner. This means you can use global mocks to control your Worker, but also means your Worker uses the subtly different module resolution behavior provided by Vite. + +`exports.default.fetch()` does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). ## Related resources From 03919d5ea8810bc99ea233681230aa8f781be99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matt=20=E2=80=98TK=E2=80=99=20Taylor?= Date: Tue, 17 Mar 2026 17:03:19 +0000 Subject: [PATCH 3/8] Apply suggestions from code review Co-authored-by: Pete Bacon Darwin --- .../workers/2026-03-11-vitest-pool-workers-vitest-4.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx index 6636d6a5d26..ce46016d9fd 100644 --- a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx +++ b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx @@ -1,5 +1,5 @@ --- -title: "`@cloudflare/vitest-pool-workers` now requires Vitest 4" +title: "`@cloudflare/vitest-pool-workers` now targets Vitest 4" description: The Workers Vitest integration has been rearchitected to support Vitest 4, dropping support for Vitest 2.x and 3.x. products: - workers From 8e2fe8dfa6a7a37a00222c00154488607ed8e419 Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Tue, 17 Mar 2026 17:13:57 +0000 Subject: [PATCH 4/8] Update with further information on Vitest 4 benefits --- .../workers/2026-03-11-vitest-pool-workers-vitest-4.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx index ce46016d9fd..6bfa1d310d5 100644 --- a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx +++ b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx @@ -3,13 +3,15 @@ title: "`@cloudflare/vitest-pool-workers` now targets Vitest 4" description: The Workers Vitest integration has been rearchitected to support Vitest 4, dropping support for Vitest 2.x and 3.x. products: - workers -date: 2026-03-11 +date: 2026-03-18 --- -`@cloudflare/vitest-pool-workers` now requires Vitest 4.1 or later. Support for Vitest 2.x and 3.x has been dropped. This release rearchitects the integration to use a Vite plugin model and simplifies the configuration and isolation APIs as the package moves toward v1. +`@cloudflare/vitest-pool-workers` now targets Vitest 4.1 or later. Support for Vitest 2.x and 3.x has been dropped in this version, though past versions remain compatible. This release rearchitects the integration to use a Vite plugin model and simplifies the configuration and isolation APIs as the package moves toward v1. If you are not ready to migrate, stay on the previous version of `@cloudflare/vitest-pool-workers`. It will continue to work with Vitest 3.x and your existing Wrangler setup. However, you will not be able to use the new features and improvements introduced in Vitest 4 or future versions of Wrangler until you upgrade. +Vitest 4 brings an [array of new capabilities](https://vitest.dev/blog/vitest-4) to your testing, and bringing this to `vitest-pool-workers` has enabled us to improve stability and fix outstanding issues relating to bundling and bindings. + ## Run the codemod A codemod is available to update your config file to Vitest 4 automatically: @@ -61,6 +63,8 @@ export default defineConfig({ ## Other breaking changes +You will need to make the following changes to your `vitest-pool-workers` code. + - **`isolatedStorage` and `singleWorker` removed.** Storage isolation is now per test file, matching Vitest's own isolation model. To make test files share the same storage, use the Vitest flags `--max-workers=1 --no-isolate`. - **`import { env, SELF } from "cloudflare:test"` removed.** Use `import { env, exports } from "cloudflare:workers"` instead. `exports.default.fetch()` behaves the same as `SELF.fetch()`, except that it does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). From f670e543d78ed01cb0b0339be1be785b8b357db6 Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Wed, 18 Mar 2026 09:26:26 +0000 Subject: [PATCH 5/8] chore: remove changelog from docs branch (moved to separate branch) --- ...026-03-11-vitest-pool-workers-vitest-4.mdx | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx diff --git a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx b/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx deleted file mode 100644 index 6bfa1d310d5..00000000000 --- a/src/content/changelog/workers/2026-03-11-vitest-pool-workers-vitest-4.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: "`@cloudflare/vitest-pool-workers` now targets Vitest 4" -description: The Workers Vitest integration has been rearchitected to support Vitest 4, dropping support for Vitest 2.x and 3.x. -products: - - workers -date: 2026-03-18 ---- - -`@cloudflare/vitest-pool-workers` now targets Vitest 4.1 or later. Support for Vitest 2.x and 3.x has been dropped in this version, though past versions remain compatible. This release rearchitects the integration to use a Vite plugin model and simplifies the configuration and isolation APIs as the package moves toward v1. - -If you are not ready to migrate, stay on the previous version of `@cloudflare/vitest-pool-workers`. It will continue to work with Vitest 3.x and your existing Wrangler setup. However, you will not be able to use the new features and improvements introduced in Vitest 4 or future versions of Wrangler until you upgrade. - -Vitest 4 brings an [array of new capabilities](https://vitest.dev/blog/vitest-4) to your testing, and bringing this to `vitest-pool-workers` has enabled us to improve stability and fix outstanding issues relating to bundling and bindings. - -## Run the codemod - -A codemod is available to update your config file to Vitest 4 automatically: - -```sh -npx jscodeshift -t node_modules/@cloudflare/vitest-pool-workers/dist/codemods/vitest-v3-to-v4.mjs vitest.config.ts -``` - -Or, without installing the package first: - -```sh -npx jscodeshift -t https://unpkg.com/@cloudflare/vitest-pool-workers/dist/codemods/vitest-v3-to-v4.mjs --parser=ts vitest.config.ts -``` - -## Configuration changes - -`defineWorkersProject` and `defineWorkersConfig` from `@cloudflare/vitest-pool-workers/config` have been replaced with a `cloudflareTest()` Vite plugin exported from `@cloudflare/vitest-pool-workers`. Options previously nested under `test.poolOptions.workers` are now passed directly to `cloudflareTest()`. - -Before: - -```ts title="vitest.config.ts" -import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config"; - -export default defineWorkersProject({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: "./wrangler.jsonc" }, - }, - }, - }, -}); -``` - -After: - -```ts title="vitest.config.ts" -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - plugins: [ - cloudflareTest({ - wrangler: { configPath: "./wrangler.jsonc" }, - }), - ], -}); -``` - -## Other breaking changes - -You will need to make the following changes to your `vitest-pool-workers` code. - -- **`isolatedStorage` and `singleWorker` removed.** Storage isolation is now per test file, matching Vitest's own isolation model. To make test files share the same storage, use the Vitest flags `--max-workers=1 --no-isolate`. - -- **`import { env, SELF } from "cloudflare:test"` removed.** Use `import { env, exports } from "cloudflare:workers"` instead. `exports.default.fetch()` behaves the same as `SELF.fetch()`, except that it does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). - -- **`import { fetchMock } from "cloudflare:test"` removed.** Mock `globalThis.fetch` directly or use ecosystem libraries such as [MSW](https://mswjs.io/). Refer to the [request mocking example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts) for an example. - -For upstream Vitest 4 breaking changes that may affect your tests, refer to the [Vitest 4 migration guide](https://vitest.dev/guide/migration#vitest-4). If you run into issues, open a discussion on the [workers-sdk GitHub repository](https://github.com/cloudflare/workers-sdk/discussions). From 9e5bf4542a877d6dab1c47c1aa0c3ca669e2de5e Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Wed, 18 Mar 2026 10:00:43 +0000 Subject: [PATCH 6/8] fix: prettier on save formatting --- .../rules-of-durable-objects.mdx | 651 ++++++++-------- .../examples/testing-with-durable-objects.mdx | 372 +++++----- .../testing/vitest-integration/test-apis.mdx | 692 +++++++++--------- .../write-your-first-test.mdx | 52 +- 4 files changed, 869 insertions(+), 898 deletions(-) diff --git a/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx b/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx index 049527ee722..661bb1e16b2 100644 --- a/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx +++ b/src/content/docs/durable-objects/best-practices/rules-of-durable-objects.mdx @@ -42,33 +42,32 @@ export interface Env { // ✅ Good use of Durable Objects: Seat booking requires coordination // All booking requests for a venue must be serialized to prevent double-booking export class SeatBooking extends DurableObject { -async bookSeat( -seatId: string, -userId: string -): Promise<{ success: boolean; message: string }> { -// Check if seat is already booked -const existing = this.ctx.storage.sql -.exec<{ user_id: string }>( -"SELECT user_id FROM bookings WHERE seat_id = ?", -seatId -) -.toArray(); - - if (existing.length > 0) { - return { success: false, message: "Seat already booked" }; - } - - // Book the seat - this is safe because Durable Objects are single-threaded - this.ctx.storage.sql.exec( - "INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)", - seatId, - userId, - Date.now() - ); - - return { success: true, message: "Seat booked successfully" }; - } + async bookSeat( + seatId: string, + userId: string + ): Promise<{ success: boolean; message: string }> { + // Check if seat is already booked + const existing = this.ctx.storage.sql + .exec<{ user_id: string }>( + "SELECT user_id FROM bookings WHERE seat_id = ?", + seatId + ) + .toArray(); + + if (existing.length > 0) { + return { success: false, message: "Seat already booked" }; + } + + // Book the seat - this is safe because Durable Objects are single-threaded + this.ctx.storage.sql.exec( + "INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)", + seatId, + userId, + Date.now() + ); + return { success: true, message: "Seat booked successfully" }; + } } export default { @@ -76,25 +75,23 @@ export default { const url = new URL(request.url); const eventId = url.searchParams.get("event") ?? "default"; - // Route to a Durable Object by event ID - // All bookings for the same event go to the same instance - const id = env.BOOKING.idFromName(eventId); - const booking = env.BOOKING.get(id); - - const { seatId, userId } = await request.json<{ - seatId: string; - userId: string; - }>(); - const result = await booking.bookSeat(seatId, userId); + // Route to a Durable Object by event ID + // All bookings for the same event go to the same instance + const id = env.BOOKING.idFromName(eventId); + const booking = env.BOOKING.get(id); - return Response.json(result, { - status: result.success ? 200 : 409, - }); - }, + const { seatId, userId } = await request.json<{ + seatId: string; + userId: string; + }>(); + const result = await booking.bookSeat(seatId, userId); + return Response.json(result, { + status: result.success ? 200 : 409, + }); + }, }; - -```` +``` A common pattern is to use Workers as the stateless entry point that routes requests to Durable Objects when coordination is needed. The Worker handles authentication, validation, and response formatting, while the Durable Object handles the stateful logic. @@ -142,7 +139,7 @@ export default { return new Response("Message sent"); }, }; -```` +``` @@ -164,16 +161,16 @@ export interface Env { // 🔴 Bad: A single Durable Object handling ALL chat rooms export class ChatRoom extends DurableObject { -async sendMessage(roomId: string, userId: string, message: string) { -// All messages for ALL rooms go through this single instance. -// This becomes a bottleneck as traffic grows. -this.ctx.storage.sql.exec( -"INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)", -roomId, -userId, -message -); -} + async sendMessage(roomId: string, userId: string, message: string) { + // All messages for ALL rooms go through this single instance. + // This becomes a bottleneck as traffic grows. + this.ctx.storage.sql.exec( + "INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)", + roomId, + userId, + message + ); + } } export default { @@ -182,10 +179,9 @@ export default { const id = env.CHAT_ROOM.idFromName("global"); const stub = env.CHAT_ROOM.get(id); - await stub.sendMessage("room-123", "user-456", "Hello!"); - return new Response("Sent"); - }, - + await stub.sendMessage("room-123", "user-456", "Hello!"); + return new Response("Sent"); + }, }; ``` @@ -211,7 +207,7 @@ Calculate your sharding requirements: Required DOs = (Total requests/second) / (Requests per DO capacity) -```` +``` ### Use deterministic IDs for predictable routing @@ -248,7 +244,7 @@ export default { return new Response("Joined game"); }, }; -```` +``` @@ -277,15 +273,14 @@ export default { const id = env.GAME_SESSION.newUniqueId(); const stub = env.GAME_SESSION.get(id); - // Store the mapping: gameCode -> id.toString() - // await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run(); - - return Response.json({ gameId: id.toString() }); - }, + // Store the mapping: gameCode -> id.toString() + // await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run(); + return Response.json({ gameId: id.toString() }); + }, }; -```` +``` ### Use parent-child relationships for related entities @@ -363,7 +358,7 @@ export class GameMatch extends DurableObject { ); } } -```` +``` @@ -395,16 +390,15 @@ export default { const gameId = url.searchParams.get("game") ?? "default"; const region = url.searchParams.get("region") ?? "wnam"; // Western North America - // Provide a location hint for where this Durable Object should be created - const id = env.GAME_SESSION.idFromName(gameId); - const stub = env.GAME_SESSION.get(id, { locationHint: region }); - - return new Response("Connected to game session"); - }, + // Provide a location hint for where this Durable Object should be created + const id = env.GAME_SESSION.idFromName(gameId); + const stub = env.GAME_SESSION.get(id, { locationHint: region }); + return new Response("Connected to game session"); + }, }; -```` +``` Location hints are suggestions, not guarantees. Refer to [Data location](/durable-objects/reference/data-location/) for available regions and details. @@ -424,7 +418,7 @@ Configure your Durable Object class to use SQLite storage in your Wrangler confi { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ] } -```` +``` @@ -439,48 +433,47 @@ export interface Env { } type Message = { -id: number; -user_id: string; -content: string; -created_at: number; + id: number; + user_id: string; + content: string; + created_at: number; }; export class ChatRoom extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - // Create tables on first instantiation - this.ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL - ) - `); - } - - async addMessage(userId: string, content: string) { - this.ctx.storage.sql.exec( - "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", - userId, - content, - Date.now() - ); - } - - async getRecentMessages(limit: number = 50): Promise { - // Use type parameter for typed results - const cursor = this.ctx.storage.sql.exec( - "SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", - limit - ); - return cursor.toArray(); - } + // Create tables on first instantiation + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + } + + async addMessage(userId: string, content: string) { + this.ctx.storage.sql.exec( + "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", + userId, + content, + Date.now() + ); + } + async getRecentMessages(limit: number = 50): Promise { + // Use type parameter for typed results + const cursor = this.ctx.storage.sql.exec( + "SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", + limit + ); + return cursor.toArray(); + } } -```` +``` Refer to [Access Durable Objects storage](/durable-objects/best-practices/access-durable-objects-storage/) for more details on the SQL API. @@ -557,7 +550,7 @@ export class ChatRoom extends DurableObject { } } } -```` +``` @@ -582,47 +575,46 @@ export interface Env { } type Message = { -id: number; -user_id: string; -content: string; -created_at: number; + id: number; + user_id: string; + content: string; + created_at: number; }; export class ChatRoom extends DurableObject { // In-memory cache - fast but NOT preserved across evictions or crashes private messageCache: Message[] | null = null; - async getRecentMessages(): Promise { - // Return from cache if available (only valid while DO is in memory) - if (this.messageCache !== null) { - return this.messageCache; - } - - // Otherwise, load from durable storage - const cursor = this.ctx.storage.sql.exec( - "SELECT * FROM messages ORDER BY created_at DESC LIMIT 100" - ); - this.messageCache = cursor.toArray(); - return this.messageCache; - } - - async addMessage(userId: string, content: string) { - // ✅ Always persist to durable storage first - this.ctx.storage.sql.exec( - "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", - userId, - content, - Date.now() - ); - - // Then update the cache (if it exists) - // If the DO crashes here, the message is still saved in SQLite - this.messageCache = null; // Invalidate cache - } + async getRecentMessages(): Promise { + // Return from cache if available (only valid while DO is in memory) + if (this.messageCache !== null) { + return this.messageCache; + } + + // Otherwise, load from durable storage + const cursor = this.ctx.storage.sql.exec( + "SELECT * FROM messages ORDER BY created_at DESC LIMIT 100" + ); + this.messageCache = cursor.toArray(); + return this.messageCache; + } + + async addMessage(userId: string, content: string) { + // ✅ Always persist to durable storage first + this.ctx.storage.sql.exec( + "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)", + userId, + content, + Date.now() + ); + // Then update the cache (if it exists) + // If the DO crashes here, the message is still saved in SQLite + this.messageCache = null; // Invalidate cache + } } -```` +``` :::caution @@ -679,7 +671,7 @@ export class ChatRoom extends DurableObject { .toArray(); } } -```` +``` @@ -730,14 +722,13 @@ export class ChatRoom extends DurableObject { Date.now() ); - // This response is held by the output gate until the write completes. - // The client only receives "Message sent" after data is safely persisted. - return "Message sent"; - } - + // This response is held by the output gate until the write completes. + // The client only receives "Message sent" after data is safely persisted. + return "Message sent"; + } } -```` +``` **Write coalescing:** Multiple storage writes without intervening `await` calls are automatically batched into a single atomic implicit transaction: @@ -782,7 +773,7 @@ export class Account extends DurableObject { await this.ctx.storage.put(`balance:${toId}`, toBalance + amount); } } -```` +``` @@ -805,18 +796,17 @@ export class Processor extends DurableObject { async processItem(id: string) { const item = await this.ctx.storage.get<{ status: string }>(`item:${id}`); - if (item?.status === "pending") { - // During this fetch, other requests CAN execute and modify storage - const result = await fetch("https://api.example.com/process"); - - // Another request may have already processed this item! - await this.ctx.storage.put(`item:${id}`, { status: "completed" }); - } - } + if (item?.status === "pending") { + // During this fetch, other requests CAN execute and modify storage + const result = await fetch("https://api.example.com/process"); + // Another request may have already processed this item! + await this.ctx.storage.put(`item:${id}`, { status: "completed" }); + } + } } -```` +``` To handle this, use optimistic locking (check-and-set) patterns: read a version number before the external call, then verify it has not changed before writing. @@ -875,7 +865,7 @@ export class ChatRoom extends DurableObject { // Other requests can be processed concurrently } } -```` +``` @@ -907,10 +897,10 @@ export interface Env { } type Message = { -id: number; -userId: string; -content: string; -createdAt: number; + id: number; + userId: string; + content: string; + createdAt: number; }; export class ChatRoom extends DurableObject { @@ -927,22 +917,21 @@ export class ChatRoom extends DurableObject { return { id, userId, content, createdAt }; } - async getMessages(limit: number = 50): Promise { - const cursor = this.ctx.storage.sql.exec<{ - id: number; - user_id: string; - content: string; - created_at: number; - }>("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", limit); - - return cursor.toArray().map((row) => ({ - id: row.id, - userId: row.user_id, - content: row.content, - createdAt: row.created_at, - })); - } - + async getMessages(limit: number = 50): Promise { + const cursor = this.ctx.storage.sql.exec<{ + id: number; + user_id: string; + content: string; + created_at: number; + }>("SELECT * FROM messages ORDER BY created_at DESC LIMIT ?", limit); + + return cursor.toArray().map((row) => ({ + id: row.id, + userId: row.user_id, + content: row.content, + createdAt: row.created_at, + })); + } } export default { @@ -950,28 +939,27 @@ export default { const url = new URL(request.url); const roomId = url.searchParams.get("room") ?? "lobby"; - const id = env.CHAT_ROOM.idFromName(roomId); - // stub is typed as DurableObjectStub - const stub = env.CHAT_ROOM.get(id); - - if (request.method === "POST") { - const { userId, content } = await request.json<{ - userId: string; - content: string; - }>(); - // Direct method call with full type checking - const message = await stub.sendMessage(userId, content); - return Response.json(message); - } - - // TypeScript knows getMessages() returns Promise - const messages = await stub.getMessages(100); - return Response.json(messages); - }, + const id = env.CHAT_ROOM.idFromName(roomId); + // stub is typed as DurableObjectStub + const stub = env.CHAT_ROOM.get(id); + + if (request.method === "POST") { + const { userId, content } = await request.json<{ + userId: string; + content: string; + }>(); + // Direct method call with full type checking + const message = await stub.sendMessage(userId, content); + return Response.json(message); + } + // TypeScript knows getMessages() returns Promise + const messages = await stub.getMessages(100); + return Response.json(messages); + }, }; -```` +``` Refer to [Invoke methods](/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/) for more details on RPC and the legacy `fetch()` handler. @@ -1037,7 +1025,7 @@ export default { return new Response(`Room ${await stub.getRoomId()} ready`); }, }; -```` +``` @@ -1070,19 +1058,18 @@ export default { const id = env.CHAT_ROOM.idFromName("lobby"); const stub = env.CHAT_ROOM.get(id); - // 🔴 Bad: Not awaiting the call - // The message ID is lost, and any errors are swallowed - stub.sendMessage("user-123", "Hello"); - - // ✅ Good: Properly awaited - const messageId = await stub.sendMessage("user-123", "Hello"); + // 🔴 Bad: Not awaiting the call + // The message ID is lost, and any errors are swallowed + stub.sendMessage("user-123", "Hello"); - return Response.json({ messageId }); - }, + // ✅ Good: Properly awaited + const messageId = await stub.sendMessage("user-123", "Hello"); + return Response.json({ messageId }); + }, }; -```` +``` ## Error handling @@ -1135,7 +1122,7 @@ export class ChatRoom extends DurableObject { // External notification logic } } -```` +``` @@ -1161,56 +1148,55 @@ export class ChatRoom extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); - if (url.pathname === "/websocket") { - // Check for WebSocket upgrade - if (request.headers.get("Upgrade") !== "websocket") { - return new Response("Expected WebSocket", { status: 400 }); - } - - const pair = new WebSocketPair(); - const [client, server] = Object.values(pair); - - // Accept the WebSocket with Hibernation API - this.ctx.acceptWebSocket(server); - - return new Response(null, { status: 101, webSocket: client }); - } - - return new Response("Not found", { status: 404 }); - } - - // Called when a message is received (even after hibernation) - async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { - const data = typeof message === "string" ? message : "binary data"; - - // Broadcast to all connected clients - for (const client of this.ctx.getWebSockets()) { - if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send(data); - } - } - } - - // Called when a WebSocket is closed - async webSocketClose( - ws: WebSocket, - code: number, - reason: string, - wasClean: boolean - ) { - // Calling close() completes the WebSocket handshake - ws.close(code, reason); - console.log(`WebSocket closed: ${code} ${reason}`); - } - - // Called when a WebSocket error occurs - async webSocketError(ws: WebSocket, error: unknown) { - console.error("WebSocket error:", error); - } + if (url.pathname === "/websocket") { + // Check for WebSocket upgrade + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("Expected WebSocket", { status: 400 }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair); + + // Accept the WebSocket with Hibernation API + this.ctx.acceptWebSocket(server); + + return new Response(null, { status: 101, webSocket: client }); + } + + return new Response("Not found", { status: 404 }); + } + + // Called when a message is received (even after hibernation) + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + const data = typeof message === "string" ? message : "binary data"; + // Broadcast to all connected clients + for (const client of this.ctx.getWebSockets()) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(data); + } + } + } + + // Called when a WebSocket is closed + async webSocketClose( + ws: WebSocket, + code: number, + reason: string, + wasClean: boolean + ) { + // Calling close() completes the WebSocket handshake + ws.close(code, reason); + console.log(`WebSocket closed: ${code} ${reason}`); + } + + // Called when a WebSocket error occurs + async webSocketError(ws: WebSocket, error: unknown) { + console.error("WebSocket error:", error); + } } -```` +``` With the Hibernation API, your Durable Object can go to sleep when there is no active JavaScript execution, but WebSocket connections remain open. When a message arrives, the Durable Object wakes up automatically. @@ -1303,7 +1289,7 @@ export class ChatRoom extends DurableObject { } } } -```` +``` @@ -1332,47 +1318,46 @@ export class GameMatch extends DurableObject { await this.ctx.storage.put("gameStarted", Date.now()); await this.ctx.storage.put("gameActive", true); - // Schedule the game to end after the duration - await this.ctx.storage.setAlarm(Date.now() + durationMs); - } - - // Called when the alarm fires - async alarm(alarmInfo?: AlarmInvocationInfo) { - const isActive = await this.ctx.storage.get("gameActive"); - - if (!isActive) { - return; // Game was already ended - } - - // End the game - await this.ctx.storage.put("gameActive", false); - await this.ctx.storage.put("gameEnded", Date.now()); - - // Calculate final scores, notify players, etc. - try { - await this.calculateFinalScores(); - } catch (err) { - // If we're almost out of retries but still have work to do, schedule a new alarm - // rather than letting our retries run out to ensure we keep getting invoked. - if (alarmInfo && alarmInfo.retryCount >= 5) { - await this.ctx.storage.setAlarm(Date.now() + 30 * 1000); - return; - } - throw err; - } - - // Schedule the next alarm only if there's more work to do - // In this case, schedule cleanup in 24 hours - await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); - } - - private async calculateFinalScores() { - // Game ending logic - } + // Schedule the game to end after the duration + await this.ctx.storage.setAlarm(Date.now() + durationMs); + } + + // Called when the alarm fires + async alarm(alarmInfo?: AlarmInvocationInfo) { + const isActive = await this.ctx.storage.get("gameActive"); + + if (!isActive) { + return; // Game was already ended + } + // End the game + await this.ctx.storage.put("gameActive", false); + await this.ctx.storage.put("gameEnded", Date.now()); + + // Calculate final scores, notify players, etc. + try { + await this.calculateFinalScores(); + } catch (err) { + // If we're almost out of retries but still have work to do, schedule a new alarm + // rather than letting our retries run out to ensure we keep getting invoked. + if (alarmInfo && alarmInfo.retryCount >= 5) { + await this.ctx.storage.setAlarm(Date.now() + 30 * 1000); + return; + } + throw err; + } + + // Schedule the next alarm only if there's more work to do + // In this case, schedule cleanup in 24 hours + await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000); + } + + private async calculateFinalScores() { + // Game ending logic + } } -```` +``` ### Make alarm handlers idempotent @@ -1419,7 +1404,7 @@ export class Subscription extends DurableObject { return true; } } -```` +``` @@ -1438,16 +1423,15 @@ export interface Env { export class ChatRoom extends DurableObject { async clearStorage() { - // Delete all storage, including any set alarm - await this.ctx.storage.deleteAll(); - - // The Durable Object instance still exists, but with empty storage - // A subsequent request will find no data - } + // Delete all storage, including any set alarm + await this.ctx.storage.deleteAll(); + // The Durable Object instance still exists, but with empty storage + // A subsequent request will find no data + } } -```` +``` ### Design for unexpected shutdowns @@ -1500,7 +1484,7 @@ export default { return new Response("OK"); }, }; -```` +``` @@ -1522,39 +1506,38 @@ import { import { describe, it, expect } from "vitest"; describe("ChatRoom", () => { -// Each test gets isolated storage automatically -it("should send and retrieve messages", async () => { -const id = env.CHAT_ROOM.idFromName("test-room"); -const stub = env.CHAT_ROOM.get(id); - - // Call RPC methods directly on the stub - await stub.sendMessage("user-1", "Hello!"); - await stub.sendMessage("user-2", "Hi there!"); - - const messages = await stub.getMessages(10); - expect(messages).toHaveLength(2); - }); - - it("can access instance internals and trigger alarms", async () => { - const id = env.CHAT_ROOM.idFromName("test-room"); - const stub = env.CHAT_ROOM.get(id); - - // Access storage directly for verification - await runInDurableObject(stub, async (instance, state) => { - const count = state.storage.sql - .exec<{ count: number }>("SELECT COUNT(*) as count FROM messages") - .one(); - expect(count.count).toBe(0); // Fresh instance due to test isolation - }); - - // Trigger alarms immediately without waiting - const alarmRan = await runDurableObjectAlarm(stub); - expect(alarmRan).toBe(false); // No alarm was scheduled - }); + // Each test gets isolated storage automatically + it("should send and retrieve messages", async () => { + const id = env.CHAT_ROOM.idFromName("test-room"); + const stub = env.CHAT_ROOM.get(id); + // Call RPC methods directly on the stub + await stub.sendMessage("user-1", "Hello!"); + await stub.sendMessage("user-2", "Hi there!"); + + const messages = await stub.getMessages(10); + expect(messages).toHaveLength(2); + }); + + it("can access instance internals and trigger alarms", async () => { + const id = env.CHAT_ROOM.idFromName("test-room"); + const stub = env.CHAT_ROOM.get(id); + + // Access storage directly for verification + await runInDurableObject(stub, async (instance, state) => { + const count = state.storage.sql + .exec<{ count: number }>("SELECT COUNT(*) as count FROM messages") + .one(); + expect(count.count).toBe(0); // Fresh instance due to test isolation + }); + + // Trigger alarms immediately without waiting + const alarmRan = await runDurableObjectAlarm(stub); + expect(alarmRan).toBe(false); // No alarm was scheduled + }); }); -```` +``` Configure Vitest in your `vitest.config.ts`: @@ -1570,7 +1553,7 @@ export default defineConfig({ }), ], }); -```` +``` For schema changes, run migrations in the constructor using `blockConcurrencyWhile()`. For class renames or deletions, use Wrangler migrations: 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 e66f218c56b..30795e9c3ac 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 @@ -17,15 +17,21 @@ Use the [`@cloudflare/vitest-pool-workers`](https://www.npmjs.com/package/@cloud Install Vitest and the Workers Vitest integration as dev dependencies: - - ```sh npm i -D vitest@^4.1.0 @cloudflare/vitest-pool-workers ``` - - - ```sh pnpm add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers ``` - - - ```sh yarn add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers ``` - + +```sh +npm i -D vitest@^4.1.0 @cloudflare/vitest-pool-workers +``` + + +```sh +pnpm add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers +``` + + +```sh +yarn add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers +``` + ## Example Durable Object @@ -44,39 +50,38 @@ export class Counter extends DurableObject { 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 { - 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 { - 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 { - this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name); - } + 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 { + 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 { + 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 { + this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name); + } } export default { @@ -84,21 +89,19 @@ export default { 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 id = env.COUNTER.idFromName(counterId); + const stub = env.COUNTER.get(id); - const count = await stub.getCount(); - return Response.json({ count }); - }, + if (request.method === "POST") { + const count = await stub.increment(); + return Response.json({ count }); + } + const count = await stub.getCount(); + return Response.json({ count }); + }, }; - -```` +``` ## Configure Vitest @@ -116,7 +119,7 @@ export default defineConfig({ }), ], }); -```` +``` Make sure your Wrangler configuration includes the Durable Object binding and SQLite migration: @@ -147,16 +150,16 @@ Create a `test/tsconfig.json` to configure TypeScript for your tests: "extends": "../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", - "types": ["@cloudflare/vitest-pool-workers"], + "types": ["@cloudflare/vitest-pool-workers"] }, - "include": ["./**/*.ts", "../src/worker-configuration.d.ts"], + "include": ["./**/*.ts", "../src/worker-configuration.d.ts"] } ``` Create an `env.d.ts` file to type the test environment: ```ts title="test/env.d.ts" -declare module "cloudflare:test" { +declare module "cloudflare:workers" { interface ProvidedEnv extends Env {} } ``` @@ -173,71 +176,69 @@ import { env } from "cloudflare:workers"; 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); + // 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); - const count2 = await stub.increment(); - expect(count2).toBe(2); + // Call RPC methods directly on the stub + const count1 = await stub.increment(); + expect(count1).toBe(1); - const count3 = await stub.increment(); - expect(count3).toBe(3); - }); + const count2 = await stub.increment(); + expect(count2).toBe(2); - it("should track separate counters independently", async () => { - const id = env.COUNTER.idFromName("test-counter"); - const stub = env.COUNTER.get(id); + const count3 = await stub.increment(); + expect(count3).toBe(3); + }); - await stub.increment("counter-a"); - await stub.increment("counter-a"); - await stub.increment("counter-b"); + it("should track separate counters independently", async () => { + const id = env.COUNTER.idFromName("test-counter"); + const stub = env.COUNTER.get(id); - expect(await stub.getCount("counter-a")).toBe(2); - expect(await stub.getCount("counter-b")).toBe(1); - expect(await stub.getCount("counter-c")).toBe(0); - }); + await stub.increment("counter-a"); + await stub.increment("counter-a"); + await stub.increment("counter-b"); - it("should reset a counter", async () => { - const id = env.COUNTER.idFromName("test-counter"); - const stub = env.COUNTER.get(id); + expect(await stub.getCount("counter-a")).toBe(2); + expect(await stub.getCount("counter-b")).toBe(1); + expect(await stub.getCount("counter-c")).toBe(0); + }); - await stub.increment("my-counter"); - await stub.increment("my-counter"); - expect(await stub.getCount("my-counter")).toBe(2); + it("should reset a counter", async () => { + const id = env.COUNTER.idFromName("test-counter"); + const stub = env.COUNTER.get(id); - await stub.reset("my-counter"); - expect(await stub.getCount("my-counter")).toBe(0); - }); + await stub.increment("my-counter"); + await stub.increment("my-counter"); + expect(await stub.getCount("my-counter")).toBe(2); - it("should isolate different Durable Object instances", async () => { - const id1 = env.COUNTER.idFromName("counter-1"); - const id2 = env.COUNTER.idFromName("counter-2"); + await stub.reset("my-counter"); + expect(await stub.getCount("my-counter")).toBe(0); + }); - const stub1 = env.COUNTER.get(id1); - const stub2 = env.COUNTER.get(id2); + it("should isolate different Durable Object instances", async () => { + const id1 = env.COUNTER.idFromName("counter-1"); + const id2 = env.COUNTER.idFromName("counter-2"); - await stub1.increment(); - await stub1.increment(); - await stub2.increment(); + const stub1 = env.COUNTER.get(id1); + const stub2 = env.COUNTER.get(id2); - // Each Durable Object instance has its own storage - expect(await stub1.getCount()).toBe(2); - expect(await stub2.getCount()).toBe(1); - }); + 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); + }); }); - -```` +``` -### Integration tests with exports +### Integration tests with `exports` -Use the `exports` binding to test your Worker's HTTP handler, which routes requests to Durable Objects: +Use `exports.default.fetch()` to test your Worker's HTTP handler, which routes requests to Durable Objects: ```ts @@ -281,8 +282,7 @@ describe("Counter Worker integration", () => { expect(dataB.count).toBe(1); }); }); -```` - +``` ### Direct access to Durable Object internals @@ -300,48 +300,46 @@ 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); - }); + 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); + }); +}); +``` ### Test isolation @@ -375,8 +373,7 @@ describe("Test isolation", () => { expect(await stub.getCount()).toBe(0); }); }); -```` - +``` ### Testing SQLite storage @@ -390,35 +387,33 @@ 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); - }); - }); + 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); + }); + }); +}); +``` ### Testing alarms @@ -463,8 +458,7 @@ describe("Durable Object alarms", () => { expect(alarmRanAgain).toBe(false); }); }); -```` - +``` To test alarms, add an `alarm()` method to your Durable Object: @@ -476,19 +470,17 @@ 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); - } + 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); + } } - -```` +``` ## Running tests @@ -497,7 +489,7 @@ Run your tests with: ```sh npx vitest -```` +``` Or add a script to your `package.json`: 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 aeec8d82f28..b16c082146d 100644 --- a/src/content/docs/workers/testing/vitest-integration/test-apis.mdx +++ b/src/content/docs/workers/testing/vitest-integration/test-apis.mdx @@ -4,424 +4,412 @@ pcx_content_type: reference sidebar: order: 5 head: [] -description: Runtime helpers for writing tests with the Workers Vitest integration. +description: Runtime helpers for writing tests, exported from `cloudflare:workers` and `cloudflare:test`. + --- -The Workers Vitest integration provides runtime helpers for writing tests. Bindings and exports are available from the `cloudflare:workers` module. Additional test utilities are available from the `cloudflare:test` module, provided by the `@cloudflare/vitest-pool-workers` package. These modules can only be imported from test files that execute in the Workers runtime. +The Workers Vitest integration provides runtime helpers for writing tests. Some helpers are exported from the `cloudflare:workers` module, and others from the `cloudflare:test` module. Both modules are provided by the `@cloudflare/vitest-pool-workers` package, but can only be imported from test files that execute in the Workers runtime. ## `cloudflare:workers` exports -- env: import("cloudflare:workers").ProvidedEnv - Exposes the - [`env` object](/workers/runtime-apis/handlers/fetch/#parameters) for use as - the second argument passed to ES modules format exported handlers. This - provides access to [bindings](/workers/runtime-apis/bindings/) that you have - defined in your [Vitest configuration - file](/workers/testing/vitest-integration/configuration/). -
- ```js - import { env } from "cloudflare:workers"; +* env: import("cloudflare:workers").ProvidedEnv - it("uses binding", async () => { - await env.KV_NAMESPACE.put("key", "value"); - expect(await env.KV_NAMESPACE.get("key")).toBe("value"); - }); - ``` + * Exposes the [`env` object](/workers/runtime-apis/handlers/fetch/#parameters) for use as the second argument passed to ES modules format exported handlers. This provides access to [bindings](/workers/runtime-apis/bindings/) that you have defined in your [Vitest configuration file](/workers/testing/vitest-integration/configuration/). - To configure the type of this value, use an ambient module type: +
- ```ts - declare module "cloudflare:workers" { - interface ProvidedEnv { - KV_NAMESPACE: KVNamespace; - } - // ...or if you have an existing `Env` type... - interface ProvidedEnv extends Env {} - } - ``` + ```js + import { env } from "cloudflare:workers"; -- exports: object - Provides access to the `main` Worker's exports. - Use `exports.default` to call the default export's handlers for integration - tests. The `main` Worker runs in the same isolate/context as tests so any - global mocks will apply to it too. + it("uses binding", async () => { + await env.KV_NAMESPACE.put("key", "value"); + expect(await env.KV_NAMESPACE.get("key")).toBe("value"); + }); + ``` - `exports.default.fetch()` does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). + To configure the type of this value, use an ambient module type: -
+ ```ts + declare module "cloudflare:workers" { + interface ProvidedEnv { + KV_NAMESPACE: KVNamespace; + } + // ...or if you have an existing `Env` type... + interface ProvidedEnv extends Env {} + } + ``` - ```js - import { exports } from "cloudflare:workers"; +* exports: object - it("dispatches fetch event", async () => { - const response = await exports.default.fetch("https://example.com"); - expect(await response.text()).toMatchInlineSnapshot(...); - }); - ``` + * Provides access to the exports of the `main` Worker. Use `exports.default.fetch()` to write integration tests against your Worker's default export handler. The `main` Worker runs in the same isolate/context as tests so any global mocks will apply to it too. Unlike the previous `SELF` binding, `exports` does not expose Assets. To test assets, use [`startDevWorker()`](/workers/testing/unstable_startworker/). + +
+ + ```js + import { exports } from "cloudflare:workers"; + + it("dispatches fetch event", async () => { + const response = await exports.default.fetch("https://example.com"); + expect(await response.text()).toMatchInlineSnapshot(...); + }); + ``` -## `cloudflare:test` module definition +## `cloudflare:test` exports -### Mocking outbound requests -To mock outbound `fetch()` requests, mock `globalThis.fetch` directly or use ecosystem libraries such as [MSW](https://mswjs.io/). Refer to the [request mocking example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/request-mocking/test/imperative.test.ts) for a complete example. ### Events -- createExecutionContext(): ExecutionContext - Creates an instance - of the [`context` object](/workers/runtime-apis/handlers/fetch/#parameters) - for use as the third argument to ES modules format exported handlers. - -- waitOnExecutionContext(ctx:ExecutionContext): Promise\ -- Use this to wait for all Promises passed to `ctx.waitUntil()` to settle, before running test assertions on any side effects. Only accepts instances of `ExecutionContext` returned by `createExecutionContext()`. - -
- - ```ts - import { env } from "cloudflare:workers"; - import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; - import { it, expect } from "vitest"; - import worker from "./index.mjs"; - - it("calls fetch handler", async () => { - const request = new Request("https://example.com"); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(...); - }); - ``` - -- createScheduledController(options?:FetcherScheduledOptions): - ScheduledController - Creates an instance of `ScheduledController` for use as - the first argument to modules-format - [`scheduled()`](/workers/runtime-apis/handlers/scheduled/) exported handlers. - -
- - ```ts - import { env } from "cloudflare:workers"; - import { - createScheduledController, - createExecutionContext, - waitOnExecutionContext, - } from "cloudflare:test"; - import { it, expect } from "vitest"; - import worker from "./index.mjs"; - - it("calls scheduled handler", async () => { - const ctrl = createScheduledController({ - scheduledTime: new Date(1000), - cron: "30 * * * *", - }); - const ctx = createExecutionContext(); - await worker.scheduled(ctrl, env, ctx); - await waitOnExecutionContext(ctx); - }); - ``` - -- - createMessageBatch(queueName:string, messages:ServiceBindingQueueMessage\[]) - - : MessageBatch - Creates an instance of `MessageBatch` for use as the first - argument to modules-format - [`queue()`](/queues/configuration/javascript-apis/#consumer) exported - handlers. - -- getQueueResult(batch:MessageBatch, ctx:ExecutionContext): Promise\ -- Gets the acknowledged/retry state of messages in the `MessageBatch`, and waits for all `ExecutionContext#waitUntil()`ed `Promise`s to settle. Only accepts instances of `MessageBatch` returned by `createMessageBatch()`, and instances of `ExecutionContext` returned by `createExecutionContext()`. - -
- - ```ts - import { env } from "cloudflare:workers"; - import { - createMessageBatch, - createExecutionContext, - getQueueResult, - } from "cloudflare:test"; - import { it, expect } from "vitest"; - import worker from "./index.mjs"; - - it("calls queue handler", async () => { - const batch = createMessageBatch("my-queue", [ - { - id: "message-1", - timestamp: new Date(1000), - body: "body-1", - }, - ]); - const ctx = createExecutionContext(); - await worker.queue(batch, env, ctx); - const result = await getQueueResult(batch, ctx); - expect(result.ackAll).toBe(false); - expect(result.retryBatch).toMatchObject({ retry: false }); - expect(result.explicitAcks).toStrictEqual(["message-1"]); - expect(result.retryMessages).toStrictEqual([]); - }); - ``` -### Durable Objects -- runInDurableObject\(stub:DurableObjectStub, callback:(instance: O, state: DurableObjectState) => R | Promise\): Promise\ -- Runs the provided `callback` inside the Durable Object that corresponds to the provided `stub`. - -
- - This temporarily replaces your Durable Object's `fetch()` handler with `callback`, then sends a request to it, returning the result. This can be used to call/spy-on Durable Object methods or seed/get persisted data. Note this can only be used with `stub`s pointing to Durable Objects defined in the `main` Worker. - -
- - ```ts - export class Counter { - constructor(readonly state: DurableObjectState) {} - - async fetch(request: Request): Promise { - let count = (await this.state.storage.get("count")) ?? 0; - void this.state.storage.put("count", ++count); - return new Response(count.toString()); - } - } - ``` - - ```ts - import { env } from "cloudflare:workers"; - import { runInDurableObject } from "cloudflare:test"; - import { it, expect } from "vitest"; - import { Counter } from "./index.ts"; - - it("increments count", async () => { - const id = env.COUNTER.newUniqueId(); - const stub = env.COUNTER.get(id); - let response = await stub.fetch("https://example.com"); - expect(await response.text()).toBe("1"); - - response = await runInDurableObject( - stub, - async (instance: Counter, state) => { - expect(instance).toBeInstanceOf(Counter); - expect(await state.storage.get("count")).toBe(1); - - const request = new Request("https://example.com"); - return instance.fetch(request); - }, - ); - expect(await response.text()).toBe("2"); - }); - ``` - -- runDurableObjectAlarm(stub:DurableObjectStub): Promise\ -- 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. - -- listDurableObjectIds(namespace:DurableObjectNamespace): Promise\ -- Gets the IDs of all objects that have been created in the `namespace`. Storage isolation is per test file, meaning objects created in a different test file will not be returned. - -
- - ```ts - import { env } from "cloudflare:workers"; - import { listDurableObjectIds } from "cloudflare:test"; - import { it, expect } from "vitest"; - - it("increments count", async () => { - const id = env.COUNTER.newUniqueId(); - const stub = env.COUNTER.get(id); - const response = await stub.fetch("https://example.com"); - expect(await response.text()).toBe("1"); - - const ids = await listDurableObjectIds(env.COUNTER); - expect(ids.length).toBe(1); - expect(ids[0].equals(id)).toBe(true); - }); - ``` +* createExecutionContext(): ExecutionContext -### D1 + * Creates an instance of the [`context` object](/workers/runtime-apis/handlers/fetch/#parameters) for use as the third argument to ES modules format exported handlers. -- applyD1Migrations(db:D1Database, migrations:D1Migration\[], migrationTableName?:string): Promise\ -- Applies all un-applied [D1 migrations](/d1/reference/migrations/) stored in the `migrations` array to database `db`, recording migrations state in the `migrationsTableName` table. `migrationsTableName` defaults to `d1_migrations`. Call the [`readD1Migrations()`](/workers/testing/vitest-integration/configuration/#readd1migrationsmigrationspath) function from the `@cloudflare/vitest-pool-workers/config` package inside Node.js to get the `migrations` array. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations. +* waitOnExecutionContext(ctx:ExecutionContext): Promise\ -### Workflows + * Use this to wait for all Promises passed to `ctx.waitUntil()` to settle, before running test assertions on any side effects. Only accepts instances of `ExecutionContext` returned by `createExecutionContext()`. -:::caution[Workflows test isolation] +
-To ensure proper test isolation in Workflows, introspectors should be disposed at the end of each test. -This is accomplished by either: + ```ts + import { env } from "cloudflare:workers"; + import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; + import { it, expect } from "vitest"; + import worker from "./index.mjs"; + + it("calls fetch handler", async () => { + const request = new Request("https://example.com"); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(await response.text()).toMatchInlineSnapshot(...); + }); + ``` -- Using an `await using` statement on the introspector. -- Explicitly calling the introspector `dispose()` method. +* createScheduledController(options?:FetcherScheduledOptions): ScheduledController -::: + * Creates an instance of `ScheduledController` for use as the first argument to modules-format [`scheduled()`](/workers/runtime-apis/handlers/scheduled/) exported handlers. -:::note[Version] +
-Available in `@cloudflare/vitest-pool-workers` version **0.9.0**! + ```ts + import { env } from "cloudflare:workers"; + import { createScheduledController, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; + import { it, expect } from "vitest"; + import worker from "./index.mjs"; + + it("calls scheduled handler", async () => { + const ctrl = createScheduledController({ + scheduledTime: new Date(1000), + cron: "30 * * * *" + }); + const ctx = createExecutionContext(); + await worker.scheduled(ctrl, env, ctx); + await waitOnExecutionContext(ctx); + }); + ``` -::: +* createMessageBatch(queueName:string, messages:ServiceBindingQueueMessage\[]): MessageBatch + + * Creates an instance of `MessageBatch` for use as the first argument to modules-format [`queue()`](/queues/configuration/javascript-apis/#consumer) exported handlers. + +* getQueueResult(batch:MessageBatch, ctx:ExecutionContext): Promise\ -- `introspectWorkflowInstance(workflow: Workflow, instanceId: string)`: Promise\ - - Creates an **introspector** for a specific Workflow instance, used to **modify** its behavior, **await** outcomes, and **clear** its state during tests. This is the primary entry point for testing individual Workflow instances with a known ID. + * Gets the acknowledged/retry state of messages in the `MessageBatch`, and waits for all `ExecutionContext#waitUntil()`ed `Promise`s to settle. Only accepts instances of `MessageBatch` returned by `createMessageBatch()`, and instances of `ExecutionContext` returned by `createExecutionContext()`. -
+
```ts import { env } from "cloudflare:workers"; - import { introspectWorkflowInstance } from "cloudflare:test"; - - it("should disable all sleeps, mock an event and complete", async () => { - // 1. CONFIGURATION - await using instance = await introspectWorkflowInstance( - env.MY_WORKFLOW, - "123456", - ); - await instance.modify(async (m) => { - await m.disableSleeps(); - await m.mockEvent({ - type: "user-approval", - payload: { approved: true, approverId: "user-123" }, - }); - }); - - // 2. EXECUTION - await env.MY_WORKFLOW.create({ id: "123456" }); - - // 3. ASSERTION - await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); - const output = await instance.getOutput(); - expect(output).toEqual({ success: true }); - - // 4. DISPOSE: is implicit and automatic here. + import { createMessageBatch, createExecutionContext, getQueueResult } from "cloudflare:test"; + import { it, expect } from "vitest"; + import worker from "./index.mjs"; + + it("calls queue handler", async () => { + const batch = createMessageBatch("my-queue", [ + { + id: "message-1", + timestamp: new Date(1000), + body: "body-1" + } + ]); + const ctx = createExecutionContext(); + await worker.queue(batch, env, ctx); + const result = await getQueueResult(batch, ctx); + expect(result.ackAll).toBe(false); + expect(result.retryBatch).toMatchObject({ retry: false }); + expect(result.explicitAcks).toStrictEqual(["message-1"]); + expect(result.retryMessages).toStrictEqual([]); }); ``` - - The returned `WorkflowInstanceIntrospector` object has the following methods: - - `modify(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to the Workflow instance's behavior. - - `waitForStepResult(step: { name: string; index?: number }): Promise`: Waits for a specific step to complete and returns a result. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to target a specific occurrence. - - `waitForStatus(status: InstanceStatus["status"]): Promise`: Waits for the Workflow instance to reach a specific [status](/workflows/build/workers-api/#instancestatus) (e.g., 'running', 'complete'). - - `getOutput(): Promise`: Returns the output value of the successful completed Workflow instance. - - `getError(): Promise<{name: string, message: string}>`: Returns the error information of the errored Workflow instance. The error information follows the form `{ name: string; message: string }`. - - `dispose(): Promise`: Disposes the Workflow instance, which is crucial for test isolation. If this function is not called and `await using` is not used, the instance's state will persist across subsequent tests. For example, an instance that becomes completed in one test will already be completed at the start of the next. - - `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. -- `introspectWorkflow(workflow: Workflow)`: Promise\ - - Creates an **introspector** for a Workflow where instance IDs are unknown beforehand. This allows for defining modifications that will apply to **all subsequently created instances**. -
+### Durable Objects + + + +* runInDurableObject\(stub:DurableObjectStub, callback:(instance: O, state: DurableObjectState) => R | Promise\): Promise\ + + * Runs the provided `callback` inside the Durable Object that corresponds to the provided `stub`. + +
+ + This temporarily replaces your Durable Object's `fetch()` handler with `callback`, then sends a request to it, returning the result. This can be used to call/spy-on Durable Object methods or seed/get persisted data. Note this can only be used with `stub`s pointing to Durable Objects defined in the `main` Worker. + +
```ts - import { env, exports } from "cloudflare:workers"; - import { introspectWorkflow } from "cloudflare:test"; - - it("should disable all sleeps, mock an event and complete", async () => { - // 1. CONFIGURATION - await using introspector = await introspectWorkflow(env.MY_WORKFLOW); - await introspector.modifyAll(async (m) => { - await m.disableSleeps(); - await m.mockEvent({ - type: "user-approval", - payload: { approved: true, approverId: "user-123" }, - }); - }); - - // 2. EXECUTION - await env.MY_WORKFLOW.create(); - - // 3. ASSERTION - const instances = introspector.get(); - for (const instance of instances) { - await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); - const output = await instance.getOutput(); - expect(output).toEqual({ success: true }); + export class Counter { + constructor(readonly state: DurableObjectState) {} + + async fetch(request: Request): Promise { + let count = (await this.state.storage.get("count")) ?? 0; + void this.state.storage.put("count", ++count); + return new Response(count.toString()); } + } + ``` - // 4. DISPOSE: is implicit and automatic here. + ```ts + import { env } from "cloudflare:workers"; + import { runInDurableObject } from "cloudflare:test"; + import { it, expect } from "vitest"; + import { Counter } from "./index.ts"; + + it("increments count", async () => { + const id = env.COUNTER.newUniqueId(); + const stub = env.COUNTER.get(id); + let response = await stub.fetch("https://example.com"); + expect(await response.text()).toBe("1"); + + response = await runInDurableObject(stub, async (instance: Counter, state) => { + expect(instance).toBeInstanceOf(Counter); + expect(await state.storage.get("count")).toBe(1); + + const request = new Request("https://example.com"); + return instance.fetch(request); + }); + expect(await response.text()).toBe("2"); }); ``` - The workflow instance doesn't have to be created directly inside the test. The introspector will capture **all** instances created after it is initialized. For example, you could trigger the creation of **one or multiple** instances via a single `fetch` event to your Worker: +* runDurableObjectAlarm(stub:DurableObjectStub): Promise\ - ```js - // This also works for the EXECUTION phase: - await exports.default.fetch("https://example.com/trigger-workflows"); + * 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. + +* 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. + +
+ + ```ts + import { env } from "cloudflare:workers"; + import { listDurableObjectIds } from "cloudflare:test"; + import { it, expect } from "vitest"; + + it("increments count", async () => { + const id = env.COUNTER.newUniqueId(); + const stub = env.COUNTER.get(id); + const response = await stub.fetch("https://example.com"); + expect(await response.text()).toBe("1"); + + const ids = await listDurableObjectIds(env.COUNTER); + expect(ids.length).toBe(1); + expect(ids[0].equals(id)).toBe(true); + }); ``` - - The returned `WorkflowIntrospector` object has the following methods: - - `modifyAll(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to all Workflow instances created after calling `introspectWorkflow`. - - `get(): Promise`: Returns all `WorkflowInstanceIntrospector` objects from instances created after `introspectWorkflow` was called. - - `dispose(): Promise`: Disposes the Workflow introspector. All `WorkflowInstanceIntrospector` from created instances will also be disposed. This is crucial to prevent modifications and captured instances from leaking between tests. After calling this method, the `WorkflowIntrospector` should not be reused. - - `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. - -- `WorkflowInstanceModifier` - - This object is provided to the `modify` and `modifyAll` callbacks to mock or alter the behavior of a Workflow instance's steps, events, and sleeps. - - `disableSleeps(steps?: { name: string; index?: number }[])`: Disables sleeps, causing `step.sleep()` and `step.sleepUntil()` to resolve immediately. If `steps` is omitted, all sleeps are disabled. - - `mockStepResult(step: { name: string; index?: number }, stepResult: unknown)`: Mocks the result of a `step.do()`, causing it to return the specified value instantly without executing the step's implementation. - - `mockStepError(step: { name: string; index?: number }, error: Error, times?: number)`: Forces a `step.do()` to throw an error, simulating a failure. `times` is an optional number that sets how many times the step should error. If `times` is omitted, the step will error on every attempt, making the Workflow instance fail. - - `forceStepTimeout(step: { name: string; index?: number }, times?: number)`: Forces a `step.do()` to fail by timing out immediately. `times` is an optional number that sets how many times the step should timeout. If `times` is omitted, the step will timeout on every attempt, making the Workflow instance fail. - - `mockEvent(event: { type: string; payload: unknown })`: Sends a mock event to the Workflow instance, causing a `step.waitForEvent()` to resolve with the provided payload. `type` must match the `waitForEvent` type. - - `forceEventTimeout(step: { name: string; index?: number })`: Forces a `step.waitForEvent()` to time out instantly, causing the step to fail. - -
- ```ts import {env} from "cloudflare:workers"; import - {introspectWorkflowInstance} from "cloudflare:test"; - - // This example showcases explicit disposal - it("should apply all modifier functions", async () => { - // 1. CONFIGURATION - const instance = await introspectWorkflowInstance(env.COMPLEX_WORKFLOW, "123456"); - - try { - // Modify instance behavior - await instance.modify(async (m) => { - // Disables all sleeps to make the test run instantly - await m.disableSleeps(); - // Mocks the successful result of a data-fetching step - await m.mockStepResult( - { name: "get-order-details" }, - { orderId: "abc-123", amount: 99.99 } - ); - // Mocks an incoming event to satisfy a `step.waitForEvent()` +### D1 + + + +* applyD1Migrations(db:D1Database, migrations:D1Migration\[], migrationTableName?:string): Promise\ + + * Applies all un-applied [D1 migrations](/d1/reference/migrations/) stored in the `migrations` array to database `db`, recording migrations state in the `migrationsTableName` table. `migrationsTableName` defaults to `d1_migrations`. Call the [`readD1Migrations()`](/workers/testing/vitest-integration/configuration/#readd1migrationsmigrationspath) function from the `@cloudflare/vitest-pool-workers/config` package inside Node.js to get the `migrations` array. Refer to the [D1 recipe](https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1) for an example project using migrations. + + +### Workflows + + + +:::caution[Workflows with storage isolation] + +To ensure proper test isolation in Workflows with per-file storage isolation, introspectors should be disposed at the end of each test. +This is accomplished by either: +* Using an `await using` statement on the introspector. +* Explicitly calling the introspector `dispose()` method. + +::: + +:::note[Version] + +Available in `@cloudflare/vitest-pool-workers` version **0.9.0**! + +::: + +* `introspectWorkflowInstance(workflow: Workflow, instanceId: string)`: Promise\ + * Creates an **introspector** for a specific Workflow instance, used to **modify** its behavior, **await** outcomes, and **clear** its state during tests. This is the primary entry point for testing individual Workflow instances with a known ID. +
+ + ```ts + import { env } from "cloudflare:workers"; + import { introspectWorkflowInstance } from "cloudflare:test"; + + it("should disable all sleeps, mock an event and complete", async () => { + // 1. CONFIGURATION + await using instance = await introspectWorkflowInstance(env.MY_WORKFLOW, "123456"); + await instance.modify(async (m) => { + await m.disableSleeps(); await m.mockEvent({ type: "user-approval", payload: { approved: true, approverId: "user-123" }, }); - - // Forces a step to fail once with a specific error to test retry logic - await m.mockStepError( - { name: "process-payment" }, - new Error("Payment gateway timeout"), - 1 // Fail only the first time - ); - - // Forces a `step.do()` to time out immediately - await m.forceStepTimeout({ name: "notify-shipping-partner" }); - - // Forces a `step.waitForEvent()` to time out - await m.forceEventTimeout({ name: "wait-for-fraud-check" }); }); // 2. EXECUTION - await env.COMPLEX_WORKFLOW.create({ id: "123456" }); + await env.MY_WORKFLOW.create({ id: "123456" }); // 3. ASSERTION - expect(await instance.waitForStepResult({ name: "get-order-details" })).toEqual({ - orderId: "abc-123", - amount: 99.99, + await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); + const output = await instance.getOutput(); + expect(output).toEqual({ success: true }); + + // 4. DISPOSE: is implicit and automatic here. + }); + ``` + * The returned `WorkflowInstanceIntrospector` object has the following methods: + * `modify(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to the Workflow instance's behavior. + * `waitForStepResult(step: { name: string; index?: number }): Promise`: Waits for a specific step to complete and returns a result. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to target a specific occurrence. + * `waitForStatus(status: InstanceStatus["status"]): Promise`: Waits for the Workflow instance to reach a specific [status](/workflows/build/workers-api/#instancestatus) (e.g., 'running', 'complete'). + * `getOutput(): Promise`: Returns the output value of the successful completed Workflow instance. + * `getError(): Promise<{name: string, message: string}>`: Returns the error information of the errored Workflow instance. The error information follows the form `{ name: string; message: string }`. + * `dispose(): Promise`: Disposes the Workflow instance, which is crucial for test isolation. If this function isn't called and `await using` is not used, isolated storage will fail and the instance's state will persist across subsequent tests. For example, an instance that becomes completed in one test will already be completed at the start of the next. + * `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. + +* `introspectWorkflow(workflow: Workflow)`: Promise\ + * Creates an **introspector** for a Workflow where instance IDs are unknown beforehand. This allows for defining modifications that will apply to **all subsequently created instances**. +
+ + ```ts + import { env, exports } from "cloudflare:workers"; + import { introspectWorkflow } from "cloudflare:test"; + + it("should disable all sleeps, mock an event and complete", async () => { + // 1. CONFIGURATION + await using introspector = await introspectWorkflow(env.MY_WORKFLOW); + await introspector.modifyAll(async (m) => { + await m.disableSleeps(); + await m.mockEvent({ + type: "user-approval", + payload: { approved: true, approverId: "user-123" }, + }); }); - // Given the forced timeouts, the workflow will end in an errored state - await expect(instance.waitForStatus("errored")).resolves.not.toThrow(); - const error = await instance.getError(); - expect(error.name).toEqual("Error"); - expect(error.message).toContain("Execution timed out"); - - } catch { - // 4. DISPOSE - await instance.dispose(); - } + // 2. EXECUTION + await env.MY_WORKFLOW.create(); - }); + // 3. ASSERTION + const instances = introspector.get(); + for(const instance of instances) { + await expect(instance.waitForStatus("complete")).resolves.not.toThrow(); + const output = await instance.getOutput(); + expect(output).toEqual({ success: true }); + } + + // 4. DISPOSE: is implicit and automatic here. + }); + ``` + The workflow instance doesn't have to be created directly inside the test. The introspector will capture **all** instances created after it is initialized. For example, you could trigger the creation of **one or multiple** instances via a single `fetch` event to your Worker: + ```js + // This also works for the EXECUTION phase: + await exports.default.fetch("https://example.com/trigger-workflows"); + ``` + + * The returned `WorkflowIntrospector` object has the following methods: + * `modifyAll(fn: (m: WorkflowInstanceModifier) => Promise): Promise`: Applies modifications to all Workflow instances created after calling `introspectWorkflow`. + * `get(): Promise`: Returns all `WorkflowInstanceIntrospector` objects from instances created after `introspectWorkflow` was called. + * `dispose(): Promise`: Disposes the Workflow introspector. All `WorkflowInstanceIntrospector` from created instances will also be disposed. This is crucial to prevent modifications and captured instances from leaking between tests. After calling this method, the `WorkflowIntrospector` should not be reused. + * `[Symbol.asyncDispose](): Promise`: Provides automatic dispose. It's invoked by the `await using` statement, which calls `dispose()`. + +* `WorkflowInstanceModifier` + * This object is provided to the `modify` and `modifyAll` callbacks to mock or alter the behavior of a Workflow instance's steps, events, and sleeps. + * `disableSleeps(steps?: { name: string; index?: number }[])`: Disables sleeps, causing `step.sleep()` and `step.sleepUntil()` to resolve immediately. If `steps` is omitted, all sleeps are disabled. + * `mockStepResult(step: { name: string; index?: number }, stepResult: unknown)`: Mocks the result of a `step.do()`, causing it to return the specified value instantly without executing the step's implementation. + * `mockStepError(step: { name: string; index?: number }, error: Error, times?: number)`: Forces a `step.do()` to throw an error, simulating a failure. `times` is an optional number that sets how many times the step should error. If `times` is omitted, the step will error on every attempt, making the Workflow instance fail. + * `forceStepTimeout(step: { name: string; index?: number }, times?: number)`: Forces a `step.do()` to fail by timing out immediately. `times` is an optional number that sets how many times the step should timeout. If `times` is omitted, the step will timeout on every attempt, making the Workflow instance fail. + * `mockEvent(event: { type: string; payload: unknown })`: Sends a mock event to the Workflow instance, causing a `step.waitForEvent()` to resolve with the provided payload. `type` must match the `waitForEvent` type. + * `forceEventTimeout(step: { name: string; index?: number })`: Forces a `step.waitForEvent()` to time out instantly, causing the step to fail. + +
+ ```ts + import { env } from "cloudflare:workers"; + import { introspectWorkflowInstance } from "cloudflare:test"; + + // This example showcases explicit disposal + it("should apply all modifier functions", async () => { + // 1. CONFIGURATION + const instance = await introspectWorkflowInstance(env.COMPLEX_WORKFLOW, "123456"); + + try { + // Modify instance behavior + await instance.modify(async (m) => { + // Disables all sleeps to make the test run instantly + await m.disableSleeps(); + + // Mocks the successful result of a data-fetching step + await m.mockStepResult( + { name: "get-order-details" }, + { orderId: "abc-123", amount: 99.99 } + ); + + // Mocks an incoming event to satisfy a `step.waitForEvent()` + await m.mockEvent({ + type: "user-approval", + payload: { approved: true, approverId: "user-123" }, + }); + + // Forces a step to fail once with a specific error to test retry logic + await m.mockStepError( + { name: "process-payment" }, + new Error("Payment gateway timeout"), + 1 // Fail only the first time + ); + + // Forces a `step.do()` to time out immediately + await m.forceStepTimeout({ name: "notify-shipping-partner" }); + + // Forces a `step.waitForEvent()` to time out + await m.forceEventTimeout({ name: "wait-for-fraud-check" }); + }); - ``` + // 2. EXECUTION + await env.COMPLEX_WORKFLOW.create({ id: "123456" }); - When targeting a step, use its `name`. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to specify the occurrence. - ``` + // 3. ASSERTION + expect(await instance.waitForStepResult({ name: "get-order-details" })).toEqual({ + orderId: "abc-123", + amount: 99.99, + }); + // Given the forced timeouts, the workflow will end in an errored state + await expect(instance.waitForStatus("errored")).resolves.not.toThrow(); + + const error = await instance.getError(); + expect(error.name).toEqual("Error"); + expect(error.message).toContain("Execution timed out"); + + } catch { + // 4. DISPOSE + await instance.dispose(); + } + }); + ``` + + When targeting a step, use its `name`. If multiple steps share the same name, use the optional `index` property (1-based, defaults to `1`) to specify the occurrence. + \ No newline at end of file diff --git a/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx b/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx index b84586603e8..a0e9c7bc3e5 100644 --- a/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx +++ b/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx @@ -30,13 +30,19 @@ First, make sure that: dev="true" /> + :::note + + Currently, the `@cloudflare/vitest-pool-workers` package _only_ works with Vitest 2.0.x - 3.2.x. + + ::: + ## Define Vitest configuration -In your `vitest.config.ts` file, use the `cloudflareTest()` Vite plugin to configure the Workers Vitest integration. +In your `vitest.config.ts` file, use the `cloudflareTest()` plugin to configure the Workers Vitest integration. You can use your Worker configuration from your [Wrangler config file](/workers/wrangler/configuration/) by specifying it with `wrangler.configPath`. -```ts title="vitest.config.ts" +```ts title = vitest.config.ts import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; @@ -49,24 +55,24 @@ export default defineConfig({ }); ``` -You can also override or define additional configuration using the `miniflare` key. This takes precedence over values set via your Wrangler config. +You can also override or define additional configuration using the `miniflare` key. This takes precedence over values set in via your Wrangler config. -For example, this configuration would add a KV namespace `TEST_NAMESPACE` that is only accessed and modified in tests. +For example, this configuration would add a KV namespace `TEST_NAMESPACE` that was only accessed and modified in tests. -```ts title="vitest.config.ts" {5-7} -export default defineConfig({ - plugins: [ - cloudflareTest({ - wrangler: { configPath: "./wrangler.jsonc" }, - miniflare: { - kvNamespaces: ["TEST_NAMESPACE"], - }, - }), - ], -}); -``` + ```js null {6-8} + export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { + kvNamespaces: ["TEST_NAMESPACE"], + }, + }), + ], + }); + ``` -For a full list of available Miniflare options, refer to the [Miniflare `WorkersOptions` API documentation](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare#interface-workeroptions). + For a full list of available Miniflare options, refer to the [Miniflare `WorkersOptions` API documentation](https://github.com/cloudflare/workers-sdk/tree/main/packages/miniflare#interface-workeroptions). For a full list of available configuration options, refer to [Configuration](/workers/testing/vitest-integration/configuration/). @@ -86,7 +92,7 @@ You should also add the output of `wrangler types` to the `include` array so tha "compilerOptions": { "moduleResolution": "bundler", "types": [ - "@cloudflare/vitest-pool-workers", // provides `cloudflare:test` types + "@cloudflare/vitest-pool-workers", // provides `cloudflare:test` and `cloudflare:workers` types ], }, "include": [ @@ -133,7 +139,10 @@ By importing the Worker we can write a unit test for its `fetch` handler. ```ts import { env } from "cloudflare:workers"; - import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; + import { + createExecutionContext, + waitOnExecutionContext, + } from "cloudflare:test"; import { describe, it, expect } from "vitest"; // Import your worker so you can unit test it import worker from "../src"; @@ -161,7 +170,7 @@ By importing the Worker we can write a unit test for its `fetch` handler. ### Integration tests -You can use the `exports` object from `cloudflare:workers` to write an integration test. `exports.default` refers to the default export defined in the main Worker. +You can use the `exports` object provided by `cloudflare:workers` to write an integration test. `exports.default.fetch()` calls the default export handler defined in the main Worker. ```ts @@ -181,8 +190,7 @@ You can use the `exports` object from `cloudflare:workers` to write an integrati When using `exports.default.fetch()` for integration tests, your Worker code runs in the same context as the test runner. This means you can use global mocks to control your Worker, but also means your Worker uses the subtly different module resolution behavior provided by Vite. - -`exports.default.fetch()` does not expose Assets. To test your assets, write an integration test using [`startDevWorker()`](/workers/testing/unstable_startworker/). +Usually this is not a problem, but to run your Worker in a fresh environment that is as close to production as possible, you can use an auxiliary Worker. Refer to [this example](https://github.com/cloudflare/workers-sdk/blob/main/fixtures/vitest-pool-workers-examples/basics-integration-auxiliary/vitest.config.ts) for how to set up integration tests using auxiliary Workers. However, using auxiliary Workers comes with [limitations](/workers/testing/vitest-integration/configuration/#workerspooloptions) that you should be aware of. ## Related resources From 84a7ef9ed4fd89735a7aea5d71390a26208b7f3c Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Wed, 18 Mar 2026 10:10:53 +0000 Subject: [PATCH 7/8] fix: address review comment on type generation --- .../migration-guides/migrate-from-miniflare-2.mdx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx b/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx index fcaf2dd62bc..cbe7912a039 100644 --- a/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx +++ b/src/content/docs/workers/testing/vitest-integration/migration-guides/migrate-from-miniflare-2.mdx @@ -89,17 +89,7 @@ To access [bindings](/workers/runtime-apis/bindings/) in your tests, use the `en }); ``` -If you are using TypeScript, add an ambient `.d.ts` declaration file defining a `ProvidedEnv` `interface` in the `cloudflare:workers` module to control the type of `env`: - -```ts -declare module "cloudflare:workers" { - interface ProvidedEnv { - NAMESPACE: KVNamespace; - } - // ...or if you have an existing `Env` type... - interface ProvidedEnv extends Env {} -} -``` +If you are using TypeScript, you need to define the type of `env` for your tests. Refer to [Define types](/workers/testing/vitest-integration/write-your-first-test/#define-types) for setup instructions. ## Storage isolation From aa9cdbd1d7d7f1c02e60d1db65cb3fe17afc6619 Mon Sep 17 00:00:00 2001 From: Matt 'TK' Taylor Date: Wed, 18 Mar 2026 10:29:13 +0000 Subject: [PATCH 8/8] fix: correct note in test guide --- .../testing/vitest-integration/write-your-first-test.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx b/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx index a0e9c7bc3e5..c135ce09566 100644 --- a/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx +++ b/src/content/docs/workers/testing/vitest-integration/write-your-first-test.mdx @@ -32,7 +32,7 @@ First, make sure that: :::note - Currently, the `@cloudflare/vitest-pool-workers` package _only_ works with Vitest 2.0.x - 3.2.x. + The `@cloudflare/vitest-pool-workers` package requires Vitest 4.1 or later. :::