How to test form validation messages
Read the message instead of screenshotting it. In the Console, input.validationMessage returns the exact string the browser will show, and input.validity names the rule that failed. Then post the same invalid payload with curl. On the form below, Chrome blocked the submit and the server stored the values anyway.
Why check this
Run this whenever a field gains or loses a constraint, and again on staging sign-off. Two questions decide whether the form is tested: does the browser refuse the value, and does the server refuse it too.
The failure it prevents is a signup endpoint that trusts the form. A tester drives the UI, sees the red bubble, files nothing. The same payload arrives later from a retry, a mobile client or an integration, and a row with age=7 and an address that has no @ lands in the database. Constraint validation is a convenience for the person typing. It is not a rule the server has agreed to.
Prerequisites
- Node 22 and Chrome. The form and the endpoint run on localhost, so no third party receives the invalid payload.
- Save the file below as
form-lab.mjsand start it withnode form-lab.mjs 9317. It serves the form athttp://localhost:9317/and the endpoint at/signup, and it validates nothing on purpose. The same file is the target for the two upload checks linked at the end.
import { createServer } from 'node:http';
const PORT = Number(process.argv[2] || 9317);
const LIMIT = 1048576; // 1 MiB of request body, multipart envelope included
const FORM = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>form lab</title></head><body>
<form id="signup" action="/signup" method="post">
<label for="email">Email</label><input id="email" name="email" type="email" required>
<label for="age">Age</label><input id="age" name="age" type="number" min="18" max="120" required>
<label for="nick">Nickname</label><input id="nick" name="nick" pattern="[a-z]{3,8}" required>
<button>Send</button></form>
<form id="docs" action="/upload" method="post" enctype="multipart/form-data">
<label for="doc">Document</label><input id="doc" name="file" type="file" accept="image/png,image/jpeg,.pdf" required>
<button>Upload</button></form></body></html>`;
const json = (res, code, obj) => {
res.writeHead(code, { 'content-type': 'application/json', connection: 'close' });
res.end(JSON.stringify(obj));
};
createServer((req, res) => {
const path = req.url.split('?')[0];
if (req.method === 'GET') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(FORM); }
const chunks = []; let n = 0, over = false;
req.on('data', (c) => {
n += c.length;
if (n <= LIMIT) return void chunks.push(c);
if (over) return; // limit already handled, keep draining
over = true;
if (path === '/upload') return json(res, 413, { error: 'file too large', limit: LIMIT, received: n });
if (path === '/upload-reset') return req.socket.destroy();
chunks.push(c.subarray(0, c.length - (n - LIMIT))); // /upload-truncate: keep LIMIT bytes, answer 200
});
req.on('end', () => {
if (res.headersSent) return;
const body = Buffer.concat(chunks);
if (path === '/signup') return json(res, 200, { stored: true, ...Object.fromEntries(new URLSearchParams(body.toString())) });
const head = body.subarray(0, 400).toString();
const name = (/filename="([^"]*)"/.exec(head) || ['', ''])[1];
const start = body.indexOf('\r\n\r\n') + 4, end = body.lastIndexOf('\r\n--');
json(res, 200, { stored: true, filename: name, fileBytes: end - start, requestBytes: n, truncated: over });
});
req.on('error', () => {});
}).listen(PORT, () => console.log(`form lab on http://localhost:${PORT}`));
- The Constraint Validation API defines
validity,validationMessage,checkValidity()andreportValidity(). - The browser figures below are one capture, Chrome 152.0.7977.76, on 2026-09-11.
Steps
- Step 1.
Open
http://localhost:9317/and read the constraint state before anything is typed. Paste this into the Console.[...document.getElementById('signup').elements].filter((el) => el.name).map((el) => { const v = el.validity; const flags = ['valueMissing','typeMismatch','patternMismatch','rangeUnderflow','badInput','customError'].filter((f) => v[f]); return `${el.name} valid=${v.valid} ${flags.join(',') || '-'} "${el.validationMessage}"`; }).join('\n');email valid=false valueMissing "Please fill out this field." age valid=false valueMissing "Please fill out this field." nick valid=false valueMissing "Please fill out this field."Three controls, one rule each, and the message exists before the user has typed anything.
- Step 2.
Put a value in each field that breaks a different rule, then run the snippet from step 1 again and read its message column.
email.value = 'not-an-email'; age.value = '7'; nick.value = 'X!';email "Please include an '@' in the email address. 'not-an-email' is missing an '@'." age "Value must be greater than or equal to 18." nick "Please match the requested format."The email message quotes the value back. The nickname message names no pattern, because the browser will not describe a regular expression. That field needs a
titleattribute or a message of your own. - Step 3.
Ask the form as a whole, and record which controls raise the
invalidevent.const f = document.getElementById('signup'), fired = []; f.addEventListener('invalid', (e) => fired.push(e.target.name), true); const ok = f.checkValidity(); `checkValidity()=${ok} invalid events: ${fired.join(', ')} first offender: ${[...f.elements].find((e) => e.name && !e.validity.valid).name}`;checkValidity()=false invalid events: email, age, nick first offender: emailcheckValidity()firesinvalidon every failing control and displays nothing.reportValidity()runs the same check and shows the bubble on the first one. Assert on the boolean. - Step 4.
Click the submit button and compare the location before and after.
const before = location.href; document.getElementById('signup').querySelector('button').click(); `url before=${before}\nurl after =${location.href}\nnavigated=${before !== location.href}`;url before=http://localhost:9317/ url after =http://localhost:9317/ navigated=falseNo navigation, so the browser refused the submit. That is the whole of what a UI test can prove here.
- Step 5.
Send the same three values to the endpoint, with no form involved.
curl -s -i -X POST http://localhost:9317/signup -d 'email=not-an-email' -d 'age=7' -d 'nick=X!'HTTP/1.1 200 OK content-type: application/json … {"stored":true,"email":"not-an-email","age":"7","nick":"X!"}stored: trueon the payload Chrome refused to send. The form is the only thing enforcing the rules, and it runs on the wrong side of the wire. - Step 6.
Start a second browser in another language and run the snippet from step 2 in it.
chrome --headless --lang=de-DE http://localhost:9317/email "Die E-Mail-Adresse muss ein @-Zeichen enthalten. In der Angabe "not-an-email" fehlt ein @-Zeichen." age "Wert muss größer als oder gleich 18 sein." nick "Deine Eingabe muss mit dem geforderten Format übereinstimmen."Same markup, same rules, different strings. With no
--langflag the Chrome on this machine answeredЗаповніть це поле.for an empty required field, because it follows the operating system language.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| validity.valid=false with one flag set | The browser applied the rule you expect | Assert on the flag name. It is the same in every locale. |
| validationMessage is an empty string | The control passes, or it is barred from validation | Read willValidate before concluding the value is good. |
| checkValidity()=false and the page posts anyway | The form carries novalidate, or a script calls form.submit() | Read form.noValidate, then find the submit call. form.submit() skips validation by design. |
| The endpoint answers 200 to the refused payload | The rule exists only in the browser | Raise the defect against the server, not the form. |
Common mistakes
What to check next
- How to check which file types a file input accepts: the one control on a form where the attribute that looks like a rule is not one.
- How to test file upload size limit: the same browser-against-server question, measured in bytes.
- How to check if a form is accessible: whether each control has a computed name, which this page does not cover.
- How to test API with invalid input: what the endpoint in step 5 should have answered.
- How to check accessibility tree: how to read the alert node that carries the message.
FAQ
How do I set custom validation messages for HTML forms?
Call el.setCustomValidity('your text'). It raises validity.customError and replaces validationMessage. It is sticky: in the capture the control still reported valid=false with the custom text after the value became valid, until setCustomValidity('') cleared it. Clear it on every input event.
How do I test HTML5 form validation without clicking through the UI?
Read element.validity and form.checkValidity() from the Console or from your automation. Both are synchronous and neither displays a bubble, so both run headless.
Does novalidate switch validation off?
It stops the browser blocking the submit. The API keeps working: with noValidate = true the capture still returned checkValidity()=false and email.validity.valid=false. The rules are evaluated, the result is ignored.
Is client-side validation worth testing at all?
Yes, for the typing experience. Treat it as a hint to the user, and test the same rules against the endpoint separately, as in step 5.
Why is the message for the pattern field so vague?
The browser will not turn a regular expression into prose. Add a title attribute, which Chrome appends to the message, or set the text with setCustomValidity().
Verified
Verified by Maks VernyChrome 152.0.7977.76curl 8.21.0Node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny