A contact form that shows "Message sent!" and sends nothing looks exactly like one that works. Unit tests won't catch it, because there is nothing to unit test: the handler does exactly what it was written to do. You notice when a customer mentions they wrote to you weeks ago.
This isn't rare. We tested 105 contact forms on live sites built with AI tools like Lovable and Bolt, and 27 of them showed a success message without sending anything (the study and its data). The handler behind most of them looked roughly like this (a reconstruction of the pattern, not a copy of any one site):
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
toast({ title: "Message sent!", description: "We'll get back to you soon." });
setFormData({ name: "", email: "", message: "" });
setIsSubmitting(false);
};
There's a loading state, a delay, a nice toast and a reset. The one thing missing is a request.
Below are two Playwright tests that catch this, and the traps we fell into while building the browser harness for that study. The tests are much smaller than the harness, but the traps apply to both.
Test 1: the typed text must leave the page
The idea is to fill the form with a value nobody would type by accident, then look for that value in the outgoing requests. If no request carries it, the form didn't send what the visitor wrote, whatever the page says afterwards.
import { test, expect } from "@playwright/test";
// A value no real visitor types, so it is easy to find in any URL or body.
const MARK = "formcheck-7f3a91";
test("contact form sends what the visitor typed", async ({ page }) => {
const carrying: string[] = [];
await page.route("**/*", async (route) => {
const req = route.request();
const body = req.postData() ?? "";
if (req.url().includes(MARK) || body.includes(MARK)) {
carrying.push(`${req.method()} ${req.url()}`);
// Answer it here, so CI never sends a real email.
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
}
return route.continue();
});
await page.goto("/contact");
await page.getByLabel("Name").fill("Form check");
await page.getByLabel("Email").fill(`${MARK}@example.com`);
await page.getByLabel("Message").fill(`Automated check ${MARK}`);
await page.getByRole("button", { name: /send/i }).click();
await expect.poll(() => carrying.length, { timeout: 5000 }).toBeGreaterThan(0);
});
The marker uses only letters, digits and a hyphen on purpose. They survive URL encoding and form encoding unchanged, so the same includes check works for JSON, FormData and application/x-www-form-urlencoded bodies. If you put the marker only in the email field, remember that @ becomes %40 in an encoded body, which is why the message field carries it too.
The fake response with success: true is a convenience. If your form talks to a real backend with a test mode, let that request through instead and assert on what the backend stored.
Test 2: no "thank you" when the backend fails
The first test proves something leaves the page. The second one proves the page doesn't celebrate too early. Make every POST fail and check that no success text appears.
test("no success message when the backend fails", async ({ page }) => {
await page.route("**/*", (route) =>
route.request().method() === "POST"
? route.fulfill({ status: 500, body: "" })
: route.continue()
);
const dialogs: string[] = [];
page.on("dialog", async (d) => {
dialogs.push(d.message());
await d.dismiss();
});
await page.goto("/contact");
await page.getByLabel("Name").fill("Form check");
await page.getByLabel("Email").fill("formcheck@example.com");
await page.getByLabel("Message").fill("Automated check");
const before = await page.locator("body").innerText();
await page.getByRole("button", { name: /send/i }).click();
// Sample for a few seconds: some toasts disappear after two or three.
const seen = new Set<string>();
for (let i = 0; i < 12; i++) {
await page.waitForTimeout(250);
const now = await page.locator("body").innerText();
for (const line of now.split("\n")) {
const t = line.trim();
if (t && !before.includes(t)) seen.add(t);
}
}
const appeared = [...seen, ...dialogs].join(" ");
expect(appeared).not.toMatch(/thank|sent|success|received|get back to you/i);
});
Comparing against the text that was on the page before the click matters. Plenty of contact pages already say "We'll get back to you within a day" in their static copy, and a plain getByText would fail on that.
The traps
Our harness ran against hundreds of sites we didn't control, so it hit cases a test for your own site may never see. Still, each of these cost us a re-run, and a couple of them show up on ordinary sites.
Analytics requests. Clicking Send often fires Google Analytics, Tag Manager, Clarity, Hotjar or PostHog. If your test counts "any request after the click" as sending, every fake form passes. Looking for the marker instead of counting requests avoids this.
Alert dialogs. Some forms say thank you with alert(). Playwright dismisses dialogs automatically if you don't handle them, so the success message never reaches your assertions. That's why the second test listens for dialog.
Toasts that vanish. We had forms that showed "Message sent!" for two or three seconds. Reading the page once after a fixed wait missed them. Sampling every 250 milliseconds caught them.
Honeypots. A hidden field that only bots fill in. Some handlers show a fake success when it has a value, so a test that fills every input looks like a bot and gets a fake "thank you". Fill fields the way a person would, by label, and you won't touch the honeypot.
Hidden selects. shadcn/Radix select menus render a hidden native <select> that the form validates against. When our harness stopped filling hidden fields to avoid honeypots, these forms started refusing to submit. On your own site, select the option through the visible menu, or call selectOption on the native element with { force: true }, since Playwright won't act on a hidden element otherwise.
mailto: without a request. A form that does location.href = "mailto:..." opens the visitor's mail app and never makes a network request. Test 1 will fail on it, and that's arguably right: the site gets nothing unless the visitor actually sends that email.
Where this fits
Put both tests in the same CI job as the rest of your end-to-end suite and run them on every deploy. They take a few seconds. If you generate pages with an AI tool, they're worth even more, because a regenerated component can quietly swap a working handler for the pattern above.
For the backend side, the rule is the same one the second test enforces: show success only after the service that stores or delivers the message confirms it. We build Formgong, a form backend that answers with success: true only once a submission is stored, which is what the first test's fake response imitates.
Do you test your contact form in CI, or do you find out from customers? We'd like to hear how other teams handle it, and if one of these tests breaks on your stack, tell us where.
Top comments (0)