• Aug 21, 2026
  • 5 min read

Testing Form States with Playwright Route Mocking (Without Sending a Single Email)

My site’s forms post to a Worker at /api/forms/submit. When double opt-in was switched on, the Worker started answering in two ways, and each form had to show the right message for both:

{ "ok": true }                              // stored, nothing else to do
{ "ok": true, "next": "confirm_email" }     // stored, please confirm by email

There are three forms on the site: the subscribe box in the middle of articles, a short enquiry in the footer, and the contact page. Each can get either reply, plus a no-JavaScript return path. That is nine states.

Testing them against production would mean real subscriptions and real confirmation emails. I wanted to see every state without sending anything. The answer was to build the site, serve it statically, and let Playwright pretend to be the Worker.

The code under test

The shared helper posts the form and now tells the caller whether the Worker started double opt-in:

export async function submitLead(form: HTMLFormElement): Promise<{ needsConfirmation: boolean }> {
  const response = await fetch(FORMS_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(formToJson(form)),
  });
  if (!response.ok) throw new Error(`leads-forms ${response.status}`);
  const body = (await response.json().catch(() => ({}))) as { next?: string };
  return { needsConfirmation: body.next === "confirm_email" };
}

Before this change it returned nothing, so every form said “you’re subscribed” even when the visitor still had to click a link. Each form now picks its message:

const { needsConfirmation } = await submitLead(form);
status.textContent = needsConfirmation ? CHECK_INBOX : "You're subscribed. Thanks!";

Without JavaScript, the form posts normally and the Worker redirects back with ?form=sent, ?form=confirm or ?form=error. A small helper reads that marker on page load, so the no-JS visitor sees the same messages.

Serve the build, not the dev server

The static build has no /api/forms/submit: that path belongs to the Worker in production. That is exactly what makes mocking clean. Nothing real can answer, so every reply comes from the test.

npm run build
cd dist && python3 -m http.server 4329

I first tried astro preview, which answered 404 for the pages I needed (I didn’t dig into why). python3 -m http.server serves dist/ with no surprises, and testing the built output is closer to what Cloudflare Pages serves anyway.

Mock the endpoint per page

Playwright’s page.route intercepts matching requests before they leave the browser. One small function covers every case: open a page, fill a form, submit, read the status element.

async function run(path, formSel, fill, reply, statusSel) {
  const page = await browser.newPage();
  await page.route("**/api/forms/submit", (r) =>
    r.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify(reply) }),
  );
  await page.goto(base + path);
  const form = page.locator(formSel).first();
  await fill(form);
  await form.locator('button[type="submit"]').click();
  await page.waitForTimeout(500);
  const text = (await page.locator(statusSel).first().textContent())?.trim();
  await page.close();
  return text;
}

Then loop over both replies:

for (const [label, reply] of [["DOI", { ok: true, next: "confirm_email" }], ["plain", { ok: true }]]) {
  console.log(label, "| article :", await run(article, "[data-article-cta-form]", cta, reply, "[data-article-cta-status]"));
  console.log(label, "| footer  :", await run("/", "#footer-newsletter-form", footer, reply, "#footer-form-status"));
  console.log(label, "| contact :", await run("/contact/", "#contact-form", contact, reply, "#contact-form-status"));
}

The no-JS path needs no mock at all. I open the page with the marker the Worker would have added:

await page.goto(base + article + "?form=confirm");

The false alarm

The first run printed the right messages for the article box and the contact form, and nothing at all for the footer, in both cases.

It looked like a bug in the footer script. It wasn’t. The footer form has a required single-line input for the problem description, and my fill function only filled a textarea, which the footer doesn’t have. With a required field empty, the browser’s built-in validation blocks the submit. No submit event, no fetch, no status text, and no error anywhere.

// wrong: the footer has no textarea
const t = f.locator("textarea");
// right: fill the field the form actually requires
await f.locator("input[name=details]").fill("hi");

After that one-line fix:

DOI   | footer  : Thanks — received. I'll reply by email. To get updates too, confirm via the email I just sent.
plain | footer  : Thanks — received. I'll reply by email.

The lesson: when a form test shows nothing, check native validation before the JavaScript. An empty status means the submit never happened, not that the handler failed. Reading the form’s required attributes first would have saved me the detour.

What the full run showed

FormPlain replynext: confirm_email
Article boxYou’re subscribed. Thanks!Almost there: check your inbox and confirm your subscription.
Footer enquiryThanks — received……plus “To get updates too, confirm via the email I just sent.”
Contact pageThanks — your message is in……plus the same confirmation hint

Plus both no-JS returns with ?form=confirm. Nine states, zero emails, under a minute.

Why not test against the real Worker?

I do, once, at the end. A real subscription with my own test address goes through the whole chain and is described in the end-to-end email pipeline test. That test proves the plumbing. It can’t cheaply cover every message variation, and each run leaves a lead behind that has to be cleaned up. The mocked browser test covers the variations; the real one proves the integration. Each does the job the other is bad at.

Takeaways

  • Test a static build and mock the API path: on a static host, nothing real can answer, so the test fully controls the replies.
  • page.route(...).fulfill() is enough to simulate every server answer, including ones production rarely sends.
  • Cover the no-JS return by opening the page with the redirect marker.
  • An empty status after “submit” usually means native validation blocked it. Fill exactly the fields the form marks required.
  • Make the shared submit helper return what the server decided, so each form can’t silently assume success.

More traps from the same week: debugging notes from a week on Cloudflare.

Next: shell traps while watching logs.