Skip to content

Commit

Permalink
feature: add support for testing Pages Functions (#5900)
Browse files Browse the repository at this point in the history
Co-authored-by: bcoll <bcoll@cloudflare.com>
  • Loading branch information
petebacondarwin and mrbbot authored May 24, 2024
1 parent 53f22a0 commit 5bf0a6b
Show file tree
Hide file tree
Showing 31 changed files with 682 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/tame-melons-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/vitest-pool-workers": minor
---

feature: add support for testing Pages Functions
27 changes: 14 additions & 13 deletions fixtures/vitest-pool-workers-examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

This directory contains example projects tested with `@cloudflare/vitest-pool-workers`. It aims to provide the building blocks for you to write tests for your own Workers. Note the examples in this directory define `singleWorker: true` options. We recommend you enable this option if you have lots of small test files. Isolated storage is enabled by default meaning writes performed in each test are automatically undone when the test finishes.

| Directory | Overview |
| --------------------------------------------------------------- | --------------------------------------------------------- |
| [✅ basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `SELF` |
| [⚠️ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] |
| [📦 kv-r2-caches](kv-r2-caches) | Isolated tests using KV, R2 and the Cache API |
| [📚 d1](d1) | Isolated tests using D1 with migrations |
| [📌 durable-objects](durable-objects) | Isolated tests using Durable Objects with direct access |
| [🚥 queues](queues) | Tests using Queue producers and consumers |
| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server |
| [🤹 request-mocking](request-mocking) | Tests using declarative/imperative outbound request mocks |
| [🔌 multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks |
| [⚙️ web-assembly](web-assembly) | Tests importing WebAssembly modules |
| [🤷 misc](misc) | Tests for other assorted Vitest features |
| Directory | Overview |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------- |
| [✅ basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `SELF` |
| [⚠️ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] |
| [⚡️ pages-functions-unit-integration-self](pages-functions-unit-integration-self) | Functions unit tests and integration tests using `SELF` |
| [📦 kv-r2-caches](kv-r2-caches) | Isolated tests using KV, R2 and the Cache API |
| [📚 d1](d1) | Isolated tests using D1 with migrations |
| [📌 durable-objects](durable-objects) | Isolated tests using Durable Objects with direct access |
| [🚥 queues](queues) | Tests using Queue producers and consumers |
| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server |
| [🤹 request-mocking](request-mocking) | Tests using declarative/imperative outbound request mocks |
| [🔌 multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks |
| [⚙️ web-assembly](web-assembly) | Tests importing WebAssembly modules |
| [🤷 misc](misc) | Tests for other assorted Vitest features |

[^1]: 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 same subtly different module resolution behaviour provided by Vite. Usually this isn't a problem, but if you'd like to run your worker in a fresh environment that's as close to production as possible, using an auxiliary worker may be a good idea. Note this prevents global mocks from controlling your worker, and requires you to build your worker ahead-of-time. This means your tests won't re-run automatically if you change your worker's source code, but could be useful if you have a complicated build process (e.g. full-stack framework).
2 changes: 2 additions & 0 deletions fixtures/vitest-pool-workers-examples/misc/test/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
declare module "cloudflare:test" {
interface ProvidedEnv {
ASSETS?: Fetcher;
KV_NAMESPACE: KVNamespace;
OTHER_OBJECT: DurableObjectNamespace;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {
createPagesEventContext,
env,
ProvidedEnv,
waitOnExecutionContext,
} from "cloudflare:test";
import { expect, it, onTestFinished } from "vitest";

// This will improve in the next major version of `@cloudflare/workers-types`,
// but for now you'll need to do something like this to get a correctly-typed
// `Request` to pass to `createPagesEventContext()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

type BareFunction = PagesFunction<ProvidedEnv, never, Record<string, never>>;

it("can consume body in middleware and in next request", async () => {
const fn: BareFunction = async (ctx) => {
const requestText = await ctx.request.text();
const nextResponse = await ctx.next();
const nextResponseText = await nextResponse.text();
return Response.json({ requestText, nextResponseText });
};
const request = new IncomingRequest("https://example.com", {
method: "POST",
body: "body",
});
const ctx = createPagesEventContext<typeof fn>({
request,
async next(nextRequest) {
const nextRequestText = await nextRequest.text();
return new Response(nextRequestText);
},
});
const response = await fn(ctx);
await waitOnExecutionContext(ctx);
expect(await response.json()).toStrictEqual({
requestText: "body",
nextResponseText: "body",
});
});

it("can rewrite to absolute and relative urls in next", async () => {
const fn: BareFunction = async (ctx) => {
const { pathname } = new URL(ctx.request.url);
if (pathname === "/absolute") {
return ctx.next("https://example.com/new-absolute", { method: "PUT" });
} else if (pathname === "/relative/") {
return ctx.next("./new", { method: "PATCH" });
} else {
return new Response(null, { status: 404 });
}
};

// Check with absolute rewrite
let request = new IncomingRequest("https://example.com/absolute");
let ctx = createPagesEventContext<typeof fn>({
request,
async next(nextRequest) {
return new Response(`next:${nextRequest.method} ${nextRequest.url}`);
},
});
let response = await fn(ctx);
await waitOnExecutionContext(ctx);
expect(await response.text()).toBe(
"next:PUT https://example.com/new-absolute"
);

// Check with relative rewrite
request = new IncomingRequest("https://example.com/relative/");
ctx = createPagesEventContext<typeof fn>({
request,
async next(nextRequest) {
return new Response(`next:${nextRequest.method} ${nextRequest.url}`);
},
});
response = await fn(ctx);
await waitOnExecutionContext(ctx);
expect(await response.text()).toBe(
"next:PATCH https://example.com/relative/new"
);
});

it("requires next property to call next()", async () => {
const fn: BareFunction = (ctx) => ctx.next();
const request = new IncomingRequest("https://example.com");
const ctx = createPagesEventContext<typeof fn>({ request });
expect(fn(ctx)).rejects.toThrowErrorMatchingInlineSnapshot(
`[TypeError: Cannot call \`EventContext#next()\` without including \`next\` property in 2nd argument to \`createPagesEventContext()\`]`
);
});

it("requires ASSETS service binding", async () => {
let originalASSETS = env.ASSETS;
onTestFinished(() => {
env.ASSETS = originalASSETS;
});
delete env.ASSETS;

const request = new IncomingRequest("https://example.com", {
method: "POST",
body: "body",
});
expect(() =>
createPagesEventContext<BareFunction>({ request })
).toThrowErrorMatchingInlineSnapshot(
`[TypeError: Cannot call \`createPagesEventContext()\` without defining \`ASSETS\` service binding]`
);
});

it("waits for waitUntil()ed promises", async () => {
const fn: BareFunction = (ctx) => {
ctx.waitUntil(ctx.env.KV_NAMESPACE.put("key", "value"));
return new Response();
};
const request = new IncomingRequest("https://example.com");
const ctx = createPagesEventContext<typeof fn>({ request });
await fn(ctx);
await waitOnExecutionContext(ctx);
expect(await env.KV_NAMESPACE.get("key")).toBe("value");
});

it("correctly types parameters", async () => {
const request = new IncomingRequest("https://example.com");

// Check no params and no data required
{
type Fn = PagesFunction<ProvidedEnv, never, Record<string, never>>;
createPagesEventContext<Fn>({ request });
createPagesEventContext<Fn>({ request, params: {} });
// @ts-expect-error no params required
createPagesEventContext<Fn>({ request, params: { a: "1" } });
createPagesEventContext<Fn>({ request, data: {} });
// @ts-expect-error no data required
createPagesEventContext<Fn>({ request, data: { b: "1" } });
}

// Check no params but data required
{
type Fn = PagesFunction<ProvidedEnv, never, { b: string }>;
// @ts-expect-error data required
createPagesEventContext<Fn>({ request });
// @ts-expect-error data required
createPagesEventContext<Fn>({ request, params: {} });
// @ts-expect-error no params but data required
createPagesEventContext<Fn>({ request, params: { a: "1" } });
// @ts-expect-error data required
createPagesEventContext<Fn>({ request, data: {} });
createPagesEventContext<Fn>({ request, data: { b: "1" } });
}

// Check no data but params required
{
type Fn = PagesFunction<ProvidedEnv, "a", Record<string, never>>;
// @ts-expect-error params required
createPagesEventContext<Fn>({ request });
// @ts-expect-error params required
createPagesEventContext<Fn>({ request, params: {} });
createPagesEventContext<Fn>({ request, params: { a: ["1"] } });
// @ts-expect-error no data but params required
createPagesEventContext<Fn>({ request, data: {} });
// @ts-expect-error no data but params required
createPagesEventContext<Fn>({ request, data: { b: "1" } });
}

// Check params and data required
{
type Fn = PagesFunction<ProvidedEnv, "a", { b: string }>;
// @ts-expect-error params required
createPagesEventContext<Fn>({ request });
// @ts-expect-error params required
createPagesEventContext<Fn>({ request, params: {} });
// @ts-expect-error data required
createPagesEventContext<Fn>({ request, params: { a: "1" } });
// @ts-expect-error params required
createPagesEventContext<Fn>({ request, data: {} });
// @ts-expect-error params required
createPagesEventContext<Fn>({ request, data: { b: "1" } });
createPagesEventContext<Fn>({
request,
params: { a: "1" },
data: { b: "1" },
});
}
});
6 changes: 6 additions & 0 deletions fixtures/vitest-pool-workers-examples/misc/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ export default defineWorkersProject({
workers: {
singleWorker: true,
miniflare: {
kvNamespaces: ["KV_NAMESPACE"],
outboundService(request) {
return new Response(`fallthrough:${request.method} ${request.url}`);
},
serviceBindings: {
ASSETS(request) {
return new Response(`assets:${request.method} ${request.url}`);
},
},
workers: [
{
name: "other",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist-functions
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# ⚡️ pages-functions-unit-integration-self

This project uses Pages Functions. Integration tests dispatch events using the `SELF` helper from the `cloudflare:test` module. Unit tests call handler functions directly. [`global-setup.ts`](global-setup.ts) builds Pages Functions into a Worker for integration testing, watching for changes.

| Test | Overview |
| --------------------------------------------------------- | ------------------------------------------------------------- |
| [integration-self.test.ts](test/integration-self.test.ts) | Basic `fetch` integration test using `SELF` **(recommended)** |
| [unit.test.ts](test/unit.test.ts) | Basic unit test calling `worker.fetch()` directly |
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Add data to the request and make all bodies uppercase
export const onRequest: PagesFunction<
Env,
never,
Data | Record<string, never>
> = async (ctx) => {
ctx.data = { user: "ada" };
const response = await ctx.next();
const text = await response.text();
return new Response(text.toUpperCase(), response);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const onRequestGet: PagesFunction<Env, "key", Data> = async (ctx) => {
const key = `${ctx.data.user}:${ctx.params.key}`;
const value = await ctx.env.KV_NAMESPACE.get(key, "stream");
return new Response(value, { status: value === null ? 204 : 200 });
};

export const onRequestPut: PagesFunction<Env, "key", Data> = async (ctx) => {
const key = `${ctx.data.user}:${ctx.params.key}`;
await ctx.env.KV_NAMESPACE.put(key, ctx.request.body ?? "");
return new Response(null, { status: 204 });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const onRequest: PagesFunction<Env, never, Data> = ({ request }) => {
return new Response(`${request.method} pong`);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
interface Env {
KV_NAMESPACE: KVNamespace;
ASSETS: Fetcher;
}

type Data = { user: string };
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.workerd.json",
"include": ["./**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import childProcess from "node:child_process";
import events from "node:events";

// Global setup runs inside Node.js, not `workerd`
export default async function () {
console.log(
"Building pages-functions-unit-integration-self and watching for changes..."
);

// Not building to `dist` here as Vitest ignores changes in `dist` by default
const buildProcess = childProcess.spawn(
"wrangler pages functions build --outdir dist-functions --watch",
{ cwd: __dirname, shell: true }
);
buildProcess.stdout.pipe(process.stdout);
buildProcess.stderr.pipe(process.stderr);

// Wait for first build
await events.once(buildProcess.stdout, "data");

// Stop watching for changes on teardown
return () => {
buildProcess.kill();
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Not found 😭</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/secure
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/take-me-home / 302
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Homepage 🏡</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Secure page 🔐</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "cloudflare:test" {
// Controls the type of `import("cloudflare:test").env`
interface ProvidedEnv extends Env {}
}
Loading

0 comments on commit 5bf0a6b

Please sign in to comment.