Skip to content

Testing a toolkit-based app

Say your Worker has an API route protected by cloudflareAccess (see Authentication). You want a Vitest test that hits that route and checks the response. There's just one problem: cloudflareAccess is waiting for a signed JWT that only the real Cloudflare Access edge normally produces, and your test runner is nowhere near that edge.

This guide covers signing a developer JWT and attaching it to a request the way cloudflareAccess's dev-token bypass expects, the vite.config.ts/vitest.config.ts pairing that keeps npm run dev and npm run test running the exact same Worker, and the @cloudflare/vitest-pool-workers APIs for exercising that Worker in real workerd.

The problem: your test runner isn't the Access edge

In production, Cloudflare Access authenticates the user and hands your Worker a signed JWT before the request ever reaches your code. cloudflareAccess just verifies it. Locally, vite dev solves this with cloudflareAccessPlugin — a plugin that emulates the login flow in the browser. But a Vitest test doesn't run in a browser at all; it calls your Worker's fetch handler directly. There's no page to click a login form on, and no connect middleware in the loop to inject a header.

That leaves two bad options: turn off authentication for tests (which you may rely on for RBAC), or point tests at a live Access team domain (slow, flaky, and it needs real credentials your CI probably doesn't have).

The mechanism: sign the token yourself

The Cloudflare Toolkit contains utilities your vitest test suite can use: signDevJwt signs a JWT using the same dev-key path cloudflareAccess's enableDevTokens option accepts — no browser, no Vite dev server, no network call to a team domain. You attach the result to a Request as a header or cookie and call worker.fetch() directly. The same verification code that runs in production checks the token; it just happens to be a token you minted a moment ago in the test file itself.

This is the same trick cloudflareAccessPlugin uses for vite dev, aimed at a different audience:

cloudflareAccessPlugin (/vite)signDevJwt (/testing)
Who's on the other endA human clicking around in a browserA Vitest assertion calling fetch directly
What it producesA login form, then cookies + redirectsA ready-to-attach JWT string
Where it runsInside vite dev's connect middlewareInside your test file, no server involved

Both require enableDevTokens: true on the Worker (or a build-time import.meta.env.DEV gate — see Authentication's fail-closed default) for the tokens they produce to be accepted. Never enable it outside test/dev code.

Vite + Vitest configuration for a Hono/Workers project

Getting @cloudflare/vite-plugin and @cloudflare/vitest-pool-workers to agree with each other — so that npm run dev and npm run test exercise the same Worker, with the same bindings and compatibility settings — is a common source of misconfiguration in Hono/Wrangler apps. Consult Cloudflare's own documentation first — they cover the underlying @cloudflare/vite-plugin and @cloudflare/vitest-pool-workers APIs this toolkit builds on top of:

Here's a single Worker (wrangler.jsonc) wired two ways: served locally via @cloudflare/vite-plugin + cloudflareAccessPlugin (see Authentication's "Developing locally" section for that half in depth), and exercised in Vitest via @cloudflare/vitest-pool-workers against that same config. Point both at one wrangler.jsonc and npm run dev/npm run test can never silently drift apart on bindings, compatibility date, or flags:

jsonc
// wrangler.jsonc
{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-01-01",
  "vars": {
    "CLOUDFLARE_TEAM_DOMAIN": "my-team.cloudflareaccess.com"
  }
}
ts
// vite.config.ts — local dev server
import { defineConfig } from "vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { cloudflareAccessPlugin } from "@adrianhall/cloudflare-toolkit/vite";
import { authPolicies } from "./src/auth-policies";

export default defineConfig({
  plugins: [cloudflareAccessPlugin({ policies: authPolicies }), cloudflare()]
});
ts
// vitest.config.ts — tests running the same Worker in real workerd
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [
    cloudflareTest({
      wrangler: { configPath: "./wrangler.jsonc" }
    })
  ],
  test: {
    include: ["test/**/*.test.ts"]
  }
});

Notice authPolicies is imported into both vite.config.ts above and the Worker itself (see Getting Started) from one shared module. Define it once, import it twice — that's what keeps dev and production from quietly disagreeing about which routes require authentication.

Testing Access-protected routes with /testing

ExportPurpose
signDevJwt(email, options?)Sign a developer JWT accepted by cloudflareAccess({ enableDevTokens: true }).
JWT_HEADERThe exact header name (cf-access-jwt-assertion) cloudflareAccess reads a bearer token from.
buildCookieHeader(token, isSecure)Build a Cookie header value carrying the token as a browser session would.
clearCookieHeader()Build a Cookie header value that clears the session (simulates logout).
COOKIE_NAMEThe exact cookie name (CF_Authorization) cloudflareAccess reads.

JWT_HEADER and COOKIE_NAME exist so a test never hardcodes the header or cookie name as a magic string that could quietly drift out of sync with what cloudflareAccess actually reads. Import them instead of typing "cf-access-jwt-assertion" or "CF_Authorization" yourself.

Pick header-based or cookie-based auth depending on what you're testing.

Header-based auth (the direct path)

Reach for this first. It's the shortest path from "I need an authenticated request" to an assertion:

ts
import { env } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import { signDevJwt, JWT_HEADER } from "@adrianhall/cloudflare-toolkit/testing";
import worker from "../src/index";

describe("GET /api/me", () => {
  it("returns the authenticated user", async () => {
    const token = await signDevJwt("alice@example.com");
    const request = new Request("http://localhost/api/me", {
      headers: { [JWT_HEADER]: token }
    });
    const response = await worker.fetch(request, env);
    expect(await response.json()).toMatchObject({ email: "alice@example.com" });
  });
});

signDevJwt's second argument accepts overrides when the default won't do: secret (must match the Worker's devSecret, if it overrode the default), lifetime (seconds, default 86400), and sub (pin a stable subject claim instead of a random one per call — handy if a test asserts on Cloudflare_Access_Identity.sub specifically):

ts
const token = await signDevJwt("alice@example.com", { sub: "user-123", lifetime: 3600 });

Reach for this when the thing you're testing actually depends on cookie behavior — a logout flow, for instance, where you need to prove the session was cleared and the route is protected again:

ts
import { env } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import {
  signDevJwt,
  buildCookieHeader,
  clearCookieHeader
} from "@adrianhall/cloudflare-toolkit/testing";
import worker from "../src/index";

describe("cookie-based auth", () => {
  it("allows logout", async () => {
    const token = await signDevJwt("alice@example.com");
    const res = await worker.fetch(
      new Request("http://localhost/api/me", {
        headers: { cookie: buildCookieHeader(token, false) }
      }),
      env
    );

    // Simulate logout and confirm the route is protected again:
    const loggedOut = await worker.fetch(
      new Request("http://localhost/api/me", { headers: { cookie: clearCookieHeader() } }),
      env
    );
    expect(loggedOut.status).toBe(401);
  });
});

buildCookieHeader's second argument (isSecure) controls whether the Secure attribute is added. Pass false for http://localhost tests — that matches how a real browser omits Secure over plain HTTP too.

Asserting on log output

Sometimes the response body alone doesn't tell you what you need to know — you also want to know what the handler logged while producing it. Cloudflare Toolkit contains a logging framework that can capture logs when testing using createCaptureTransport.

ts
import { createCaptureTransport } from "@adrianhall/cloudflare-toolkit/logging";
import { cloudflareLogger } from "@adrianhall/cloudflare-toolkit/hono";

const capture = createCaptureTransport();
app.use(cloudflareLogger({ level: "trace", transport: capture }));

// ...make a request through `app.fetch(...)` or `worker.fetch(...)`...

expect(capture.find("warn")).toHaveLength(0); // nothing unexpected was logged

Running your Worker in real workerd

Everything so far calls worker.fetch(request, env) directly. Once vitest.config.ts wires up cloudflareTest(), your test files run inside real workerd itself, with two additional runtime-provided modules available for the cases where a plain fetch() call isn't enough:

  • cloudflare:test provides createExecutionContext() andwaitOnExecutionContext(ctx). Reach for these when your handler kicks off background work with ctx.waitUntil(...) — without waiting on the context, your test can pass before that work has actually finished, hiding a bug in it:

    ts
    import { env } from "cloudflare:workers";
    import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
    import { it, expect } from "vitest";
    import worker from "../src/index";
    
    it("waits for background work before asserting", async () => {
      const request = new Request("http://localhost/api/me");
      const ctx = createExecutionContext();
      const response = await worker.fetch(request, env, ctx);
      await waitOnExecutionContext(ctx);
      expect(response.status).toBe(200);
    });
  • cloudflare:workers provides env, typed via an ambient ProvidedEnv module augmentation that matches your wrangler.jsonc bindings. Use this when you want to read or write a binding (KV or D1, for example) without going through a request at all:

    ts
    import { env } from "cloudflare:workers";
    import { it, expect } from "vitest";
    
    it("uses a binding directly", async () => {
      await env.MY_KV.put("key", "value");
      expect(await env.MY_KV.get("key")).toBe("value");
    });

For anything beyond this — D1 migrations, Pages ASSETS bindings, multi-Worker setups — go straight to Cloudflare's own Reference Samples and Test APIs pages. This toolkit doesn't wrap or replace any of that runtime surface — it only solves the Access-token problem @cloudflare/vitest-pool-workers doesn't try to.

See also