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

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}`));

Steps

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

  2. 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 title attribute or a message of your own.

  3. Step 3.

    Ask the form as a whole, and record which controls raise the invalid event.

    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: email

    checkValidity() fires invalid on every failing control and displays nothing. reportValidity() runs the same check and shows the bubble on the first one. Assert on the boolean.

  4. 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=false

    No navigation, so the browser refused the submit. That is the whole of what a UI test can prove here.

  5. 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: true on 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.

  6. 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 --lang flag 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

Sign: A test asserts the text 'Please fill out this field.' and fails on a colleague's machine.Cause: The message is written by the browser in the browser's language, not by your markup. The same empty field returned Заповніть це поле. under this machine's default locale, Fülle dieses Feld aus. under --lang=de-DE, and the English string under --lang=en-US. Assert on validity.valueMissing, and assert on text only for messages you set yourself with setCustomValidity().
Sign: A selector written to grab the validation bubble finds nothing in the DOM.Cause: The bubble is browser chrome, not document content. After reportValidity() the length of document.body.innerHTML was 596 before and 596 after, and zero elements contained the message text. Chrome does hand it to assistive technology: the accessibility tree gained a node with role alert and live=assertive carrying the same string. A DOM query and an accessibility query disagree here, and only the second one finds the message.
Sign: A disabled field holds an out-of-range value and the form still reports valid.Cause: A disabled control is barred from constraint validation. In the capture, age.value was 7 with age.validity.rangeUnderflow=true, age.willValidate=false and form.checkValidity()=true. The ValidityState keeps reporting the problem while the form ignores it. Disabled fields are also left out of the payload, so the server receives no age at all.
Sign: A test writes a letter into a number field and no error appears.Cause: Assigning input.value = 'abc' to type=number leaves the value as an empty string, so the flag raised is valueMissing and badInput stays false. Typing the same characters through the keyboard produced an empty value with badInput=true and the message Please enter a number. To test bad input, drive the keyboard rather than the property.

What to check next

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.

intermediate12 minpublished updated Maks Verny