This article was drafted with AI assistance; the code was run and tested before publishing.
A contact or enquiry form is often the single most important thing on a business website. It is also one of the most commonly broken. Fields that accept garbage, errors that appear nowhere, double submissions that create duplicate leads, a success message that shows even when the request failed. None of these show up when you click through the form once on your laptop.
This is the checklist we run before any lead form goes live, along with a small Node.js example you can copy and adapt. Every test in it uses synthetic data only.
1. Decide what "valid" actually means
Before writing a single test, agree on the rules. For a typical service-business enquiry form:
| Field | Required | Rule |
|---|---|---|
| Name | Yes | At least 2 characters after trimming |
| Phone | Yes | 10–13 digits, optional leading +, spaces and dashes allowed |
| No | If present, must look like an email | |
| Message | No | Trimmed, capped at 1,000 characters |
| Consent | Yes | Must be explicitly true
|
Writing this table first saves arguments later. It also tells you exactly which test cases you need.
2. Validate on the client and the server
Client-side validation is for the user: it gives instant feedback. Server-side validation is for you: it is the only validation you can trust, because anyone can bypass the browser.
Here is a minimal Express endpoint that enforces the table above, rejects bots with a honeypot field and ignores duplicate submits:
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json());
const seen = new Map(); // use Redis or your DB in production
const DEDUPE_WINDOW_MS = 10 * 60 * 1000;
function validateLead(body) {
const errors = {};
const name = (body.name || "").trim();
const phone = (body.phone || "").replace(/[\s-]/g, "");
const email = (body.email || "").trim().toLowerCase();
if (name.length < 2) errors.name = "Please enter your name.";
if (!/^\+?\d{10,13}$/.test(phone)) errors.phone = "Please enter a valid phone number.";
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Please check your email address.";
if (body.consent !== true) errors.consent = "Please confirm we can contact you.";
return { errors, clean: { name, phone, email, message: (body.message || "").trim().slice(0, 1000) } };
}
app.post("/api/leads", (req, res) => {
if (req.body.company_website) return res.status(200).json({ ok: true }); // honeypot
const { errors, clean } = validateLead(req.body);
if (Object.keys(errors).length) return res.status(422).json({ ok: false, errors });
const key = crypto.createHash("sha256").update(clean.phone + "|" + clean.message).digest("hex");
const last = seen.get(key);
if (last && Date.now() - last < DEDUPE_WINDOW_MS) {
return res.status(200).json({ ok: true, duplicate: true });
}
seen.set(key, Date.now());
// save to CRM / database and notify sales here
return res.status(201).json({ ok: true });
});
module.exports = app;
A few deliberate choices worth pointing out:
- 422 with per-field errors. The front end can map each error to its field instead of showing a vague "something went wrong".
-
The honeypot returns 200. A bot that fills the hidden
company_websitefield gets a success response and moves on, so it never learns to avoid the trap. - Duplicates return 200, not an error. From the user's point of view the submission worked. You just don't create a second lead.
3. Automate the unhappy paths
The happy path is the one you'll test manually anyway. The value of automation is in the cases nobody remembers to click through. This test file uses only Node's built-in assert and fetch (Node 18+):
const assert = require("node:assert");
const app = require("./server");
const server = app.listen(0);
const base = `http://localhost:${server.address().port}/api/leads`;
const post = (body) =>
fetch(base, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
.then(async (r) => ({ status: r.status, json: await r.json() }));
// Synthetic data only, never real customer details.
const valid = { name: "Test Lead", phone: "+91 90000 00001", email: "test+lead1@example.com", message: "Form QA run", consent: true };
(async () => {
let r = await post({});
assert.equal(r.status, 422, "empty form must be rejected");
assert.ok(r.json.errors.name && r.json.errors.phone && r.json.errors.consent);
r = await post({ ...valid, phone: "12345" });
assert.equal(r.status, 422, "short phone must be rejected");
r = await post({ ...valid, email: "not-an-email" });
assert.equal(r.status, 422, "bad email must be rejected");
r = await post(valid);
assert.equal(r.status, 201, "valid lead must be accepted");
r = await post(valid);
assert.equal(r.json.duplicate, true, "double submit must be detected");
r = await post({ ...valid, company_website: "spam.example" });
assert.equal(r.status, 200, "honeypot submit returns 200 without saving");
console.log("All lead form checks passed");
server.close();
})().catch((e) => { console.error(e); server.close(); process.exit(1); });
Run it with node test.js. We ran exactly this code on Node 22 with Express 4 and all six checks passed. Wire it into CI and you'll know immediately if someone later "simplifies" the validation.
4. Check the states the user actually sees
Automated API tests don't cover the interface. Before launch, walk through each of these states in the browser:
- Empty submit. Every required field shows its own message, and focus moves to the first invalid field.
- Partially wrong submit. Only the wrong fields show errors; correct values are not wiped.
- Submitting. The button is disabled and shows a loading state, so a double-click can't send twice.
- Network failure. Turn on offline mode in DevTools and submit. The user should see a clear error and keep what they typed. A success message here is a serious bug.
- Success. The confirmation says what happens next ("We'll call you within one working day") rather than just "Thanks!"
5. Test on a real phone
Most enquiries on local business sites come from mobile. On an actual device, check that:
- the phone field opens a numeric keypad (
type="tel",inputmode="tel") - the email field uses
type="email"andautocomplete="email" - labels stay visible (placeholder-only labels disappear as soon as someone types)
- the submit button is reachable without zooming and isn't hidden behind a sticky chat widget
- the page doesn't jump when the on-screen keyboard opens
6. Keep test data clean and private
- Use obvious fake values (
Test Lead,+91 90000 00001,test+lead1@example.com) so test entries are easy to filter out later. - Never paste a real customer's details into a staging form "just to check".
- If the form posts to a live CRM, either point staging at a sandbox or tag test leads so sales doesn't call them.
7. Verify the whole journey end to end
Finally, submit one synthetic lead on the production site right after launch and follow it all the way through:
- Does the success state appear?
- Does the lead arrive in the CRM or inbox with every field intact, including any non-English characters?
- Does the notification reach the person who is supposed to call?
- Does your analytics or ad conversion tag fire exactly once?
- Does the thank-you page (if you use one) load and stay out of search indexing?
If any step fails, you've found the bug before a real customer did.
The short version
- Write the validation rules down first.
- Validate on the server, not just in the browser.
- Automate empty, invalid, duplicate and bot submissions.
- Check loading, error and success states by hand, including offline.
- Test on a real phone.
- Use synthetic data and follow one test lead all the way to the person who calls.
We build and test lead forms like this as part of our website design and development work at Raybotix Digital in Ahmedabad.
Top comments (0)