diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 1827c01bb..3223c3722 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -8,8 +8,7 @@ module.exports = { "link", { rel: "icon", - href: - "data:image/svg+xml,๐Ÿ”ฅ", + href: "data:image/svg+xml,๐Ÿ”ฅ", }, ], ["meta", { property: "og:description", content: pkg.description }], @@ -49,6 +48,7 @@ module.exports = { { text: "๐Ÿ—บ Source Maps", link: "/source-maps.html" }, { text: "๐Ÿ•ธ Web Standards", link: "/standards.html" }, { text: "๐Ÿ“„ HTMLRewriter", link: "/html-rewriter.html" }, + { text: "๐Ÿคน Jest Environment", link: "/jest.html" }, ], }, { diff --git a/docs/jest.md b/docs/jest.md new file mode 100644 index 000000000..4daf332d0 --- /dev/null +++ b/docs/jest.md @@ -0,0 +1,229 @@ +# ๐Ÿคน Jest Environment + +Miniflare includes a custom Jest environment that allows you to run your unit +tests within the Miniflare sandbox. Note that Jest ≥ 27 is required. + +## Setup + +The Miniflare environment isn't installed by default, install it and Jest with: + +```shell +$ npm install -D jest-environment-miniflare jest +``` + +In the following examples, we'll assume your `package.json` contains +`"type": "module"`, and that you're using a tool to bundle your worker. See +[โšก๏ธ Developing with esbuild](/esbuild.html) for an example. + +To enable the Miniflare environment, set the +[`testEnvironment` option](https://jestjs.io/docs/configuration#testenvironment-string) +in your Jest configuration: + +```js +// jest.config.js +export default { + testEnvironment: "miniflare", + // Configuration is automatically loaded from `.env`, `package.json` and + // `wrangler.toml` files by default, but you can pass any additional Miniflare + // API options here: + testEnvironmentOptions: { + bindings: { KEY: "value" }, + kvNamespaces: ["TEST_NAMESPACE"], + }, +}; +``` + +## Writing and Running Tests + +The Miniflare environment lets us import our worker's functions with regular +`import` syntax. We can write a test the following worker like so: + +```js +// src/index.js +addEventListener("fetch", (event) => { + event.respondWith(handleRequest(event.request)); +}); + +// Assuming you've got a build tool that removes `export`s when you actually +// deploy your worker +export async function handleRequest(request) { + return new Response(`URL: ${request.url} KEY: ${KEY}`); +} +``` + +```js +// test/index.spec.js +import { handleRequest } from "../src/index.js"; + +test("responds with url", async () => { + const req = new Request("http://localhost/"); + const res = await handleRequest(req); + expect(await res.text()).toBe("URL: http://localhost/ KEY: value"); +}); +``` + +Modules support is still experimental in Jest and requires the +`--experimental-vm-modules` flag. To run this test: + +```shell +$ NODE_OPTIONS=--experimental-vm-modules npx jest +``` + +## Isolated Storage + +The Miniflare environment will use isolated storage for KV namespaces, caches, +and Durable Objects in each test. This essentially means any changes you make in +a test or `describe`-block are automatically undone afterwards. The isolated +storage is copied from the parent `describe`-block, allowing you to seed data in +`beforeAll` hooks. + +As an example, consider the following tests: + +```js +// Gets the array +async function get() { + const jsonValue = await TEST_NAMESPACE.get("array"); + return JSON.parse(jsonValue ?? "[]"); +} + +// Pushes an item onto the end of the array +async function push(item) { + const value = await get(); + value.push(item); + await TEST_NAMESPACE.put("array", JSON.stringify(value)); +} + +beforeAll(async () => { + await push("beforeAll"); +}); + +beforeEach(async () => { + // This runs in each tests' isolated storage environment + await push("beforeEach"); +}); + +test("test 1", async () => { + // This push(1) will only mutate the isolated environment + await push(1); + expect(await get()).toEqual(["beforeAll", "beforeEach", 1]); +}); + +test("test 2", async () => { + await push(2); + // Note that push(1) from the previous test has been "undone" + expect(await get()).toEqual(["beforeAll", "beforeEach", 2]); +}); + +describe("describe", () => { + beforeAll(async () => { + await push("describe: beforeAll"); + }); + + beforeEach(async () => { + await push("describe: beforeEach"); + }); + + test("test 3", async () => { + await push(3); + expect(await get()).toEqual([ + // All beforeAll's run before beforeEach's + "beforeAll", + "describe: beforeAll", + "beforeEach", + "describe: beforeEach", + 3, + ]); + }); + + test("test 4", async () => { + await push(4); + expect(await get()).toEqual([ + "beforeAll", + "describe: beforeAll", + "beforeEach", + "describe: beforeEach", + 4, + ]); + }); +}); +``` + +Note that bindings (e.g. variables, KV namespaces, etc) are only included in the +global scope when you're using a `service-worker` format worker. In `modules` +mode, you can use the `getMiniflareBindings` global method: + +```js +const { TEST_NAMESPACE } = getMiniflareBindings(); +``` + +Note also that storage persistence options (`kvPersist`, `cachePersist`, and +`durableObjectsPersist`) are ignored by the Miniflare Jest environment. + +## Durable Objects + +When testing Durable Objects, Miniflare needs to run your script itself to +extract exported Durable Object classes. Miniflare should be able to auto-detect +your script from your `package.json` or `wrangler.toml` file, but you can also +set it manually in Jest configuration: + +```js +// src/index.mjs +export class TestObject { + constructor(state) { + this.storage = state.storage; + } + + async fetch() { + const count = (await this.storage.get("count")) + 1; + this.storage.put("count", count); + return new Response(count.toString()); + } +} +``` + +```js +// jest.config.js +export default { + testEnvironment: "miniflare", + testEnvironmentOptions: { + modules: true, + scriptPath: "./src/index.mjs", + durableObjects: { + TEST_OBJECT: "TestObject", + }, + }, +}; +``` + +To access Durable Object storage in tests, use the +`getMiniflareDurableObjectStorage` global method: + +```js +test("increments count", async () => { + // Durable Objects requires modules mode so bindings aren't accessible via the + // global scope + const { TEST_OBJECT } = getMiniflareBindings(); + const id = TEST_OBJECT.newUniqueId(); + + // Seed Durable Object storage (isolated storage rules from above also apply) + const storage = await getMiniflareDurableObjectStorage(id); + await storage.put("count", 3); + + // Increment the count + const stub = TEST_OBJECT.get(id); + const res = await stub.fetch("http://localhost/"); + expect(await res.text()).toBe("4"); + + // Check storage updated + expect(await storage.get("count")).toBe(4); +}); +``` + + +::: warning +Note that if you also import `../src/index.mjs` in your test, your script will +be run twice, as Miniflare and Jest don't share a module cache. This usually +won't be a problem, but be aware you may have issues with unique `Symbol()`s or +`instanceof`s. +::: +