Testing

Write unit and end-to-end tests for your app, run them against a live preview deploy, and (optionally) block merges to main until they pass. Tests run inside your deployed Worker with the same env bindings as your routes.

Write tests in api/test.js

api/test.js is a special file (not an HTTP route). Export a tests object mapping a test name to a function. Each function receives { fetch, assert, env } and fails by throwing (or by a failed assert):

// api/test.js
import { total } from "./cart.js"; // your own code, for unit tests

export const tests = {
  // Unit test — call your app's functions directly.
  "total() sums line items": ({ assert }) => {
    assert(total([{ price: 200, qty: 2 }, { price: 50, qty: 1 }]) === 450);
  },

  // E2E test — hit your real routes over HTTP on the preview deploy.
  "home page loads": async ({ fetch, assert }) => {
    const res = await fetch("/");
    assert(res.status === 200, `expected 200, got ${res.status}`);
  },

  "api/echo round-trips JSON": async ({ fetch, assert }) => {
    const res = await fetch("/api/echo", {
      method: "POST",
      body: JSON.stringify({ hi: 1 }),
    });
    const json = await res.json();
    assert(json.hi === 1, "echo mismatch");
  },
};
  • Unit tests import and call your own modules directly — fast, no network.
  • E2E tests use fetch(path) to make real HTTP requests to your app’s own routes on the running preview.
  • assert(condition, message?) throws when the condition is falsy; any thrown error marks the test failed and records its message.
  • Tests run with your app’s env (e.g. env.DB, env.STORAGE) — on a preview deploy these point at an isolated preview database and bucket. Writes there never touch production. Each branch gets its own isolated preview data, so tests on one branch never interfere with another’s.
Sandboxes start empty by default. A preview sandbox is schema-only — your app tables start empty, no production data is copied in, so you seed exactly what each test needs. This is fastest (nothing is copied) and keeps production data out of the sandbox. If you genuinely need realistic data, an owner/admin can run set_preview_data_mode with mode: "light" (login/account data only) or mode: "full" (app rows too); it takes effect on the next deploy or reset_playground. Switch back with mode: "none".
If you opt into light or full data, the sandbox is not empty — don’t hard-code fixture rows that can collide with the copied data, e.g. inserting a fixed id with an email/name that already exists on a UNIQUE column. An INSERT OR IGNORE is then silently skipped, your fixed id won’t exist, and anything referencing it fails with a confusing FOREIGN KEY constraint failed. Use unique/randomized fixture values, or DELETE the rows your test owns in setup, and don’t assume a row count.

Run them

Tests run against a branch’s preview deploy, so deploy the branch first, then run:

  • Deploy the branch to a preview (deploy_app).
  • Run run_tests (MCP) or the Run tests button on the app’s dashboard page. It returns { status, total, passed, failed, results }.

The run is recorded against the exact commit that was on the preview, so a green result only counts for that commit — commit again and you re-run.

The merge-to-main gate

If your app has an api/test.js, a pull request into main will only merge when the source branch’s latest commit has a passing test run. So the loop is:

edit  →  deploy_app (preview)  →  run_tests (until green)  →  merge_pull_request
An org owner or admin can bypass a red or missing gate with merge_pull_request(force: true) — use it only when you knowingly accept failing or absent tests. Apps with no api/test.js merge exactly as before; testing is opt-in per app.

Notes

  • Test source in api/test.js is never served publicly — like api/cron.js, it’s a reserved module, not a route.
  • Testing is available on every plan, including Free. See Branches, PRs & merges for the surrounding workflow.
  • Big suites run in chunks that continue automatically — a large suite (100+ tests) completes across several rounds, up to about four minutes of total test time, so expect the call to take a little longer. Only a suite too slow for even that reports partial results (the tests that finished, plus what didn't run) as an incomplete run — keep individual tests quick, or pass a filter to run a subset while iterating.
  • Only one test run per branch happens at a time — they’re serialized so they can’t contend on the branch’s preview database. If a run is already going (for example, one that started automatically right after a deploy), run_tests waits for it and returns that run’s result — so right after a deploy you can just call run_tests and get the auto-run’s outcome. Only if the in-flight run is still going after ~75s does it ask you to call again (coverage accumulates, so nothing is lost).