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.

Start from a clean slate. A preview sandbox keeps its data between runs, so a run that dies part-way can leave rows behind that make the next run fail, often on an unrelated test (a count, a list). Two ways to stay clean: have each test set up and tear down its own fixtures, and, if a failure looks like stale leftovers, pass resetData: true to run_tests for a fresh, empty-of-app-data start (your schema and the built-in login tables are preserved). It’s faster than reset_playground and always runs the suite from the top.

How tests run, and the time limit

  • Tests run one at a time, in the order they’re listed. You don’t need to serialize them yourself.
  • Each test gets up to 40 seconds. A test that takes longer is recorded as inconclusive (not a failure) and the run moves on to the next test.
  • A test that hits the limit can’t be stopped: it keeps running in the background. Variables at the top level of your test file also stay in memory between rounds of the same run, and between runs.
Don’t chain tests through a shared queue or lock. A wrapper that makes each test wait for the previous one (a promise kept at the top of the file) turns one slow test into a stuck suite: the slow test keeps running after its time is up, every later test waits behind it, and they all time out, even ones that do no I/O. This can keep happening in later runs too. If you see “still running in this worker” in a timed-out test’s error, look for such a wrapper and remove it. Give fixtures unique ids per run instead, so tests can’t step on each other.
// Don't: a module-level queue
let queue = Promise.resolve();
const serial = (fn) => (t) => (queue = queue.then(() => fn(t)));

// Do: plain tests, unique fixture ids
const RUN = Math.random().toString(36).slice(2, 8);
export const tests = {
  "creates an order": async ({ env, assert }) => {
    const id = "test-order-" + RUN;
    // ...
  },
};

Keep each test well under the limit. Most of a slow test’s time goes to separate database calls, so group writes with env.DB.batch([...]) and set up only the rows the test needs.

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).