HTML Form Validation in Cypress

How to check if the form is valid before submitting it.

HTML standard has nice built-in form element validation rules, widely supported by the modern browsers. No need to bring a 3rd party library just to require a form input to have a value, or for doing simple numerical checks. For example, if we want to ask the user for the item's name and its quantity, we can write:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<form id="form-validation" action="/action_page.php">
<div>
<label for="item">Item:</label>
<input id="item" type="text" name="item" required />
</div>

<div>
<label for="quantity">Quantity (between 1 and 5):</label>
<input
type="number"
id="quantity"
name="quantity"
min="1"
max="5"
required
/>
</div>

<input type="submit" />
</form>

Any user trying to submit this form while breaking the rules is going to see the error messages shown by the browser, and the form is not going to be submitted.

Form validation messages

Notice how the browser stops the form submission on the first broken rule.

The error popups are shown by the browser - they are NOT part of the page's DOM. Thus we cannot query them from the Cypress test. How do we check our form elements from the end-to-end tests to ensure the rules are set up correctly?

CSS pseudo-classes

The HTML standard defines several CSS pseudo-classes for finding the invalid and valid input elements. For example the :invalid pseudo-class is present on every input element currently breaking its validation rules.

:invalid pseudo-class

We can write a Cypress test to check the presence of form elements using this class.

🧭 You can find these form validation examples at https://glebbahmutov.com/cypress-examples under "Recipes" section. You can also find more information about form validation by searching the Cypress docs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <reference types="cypress" />
describe('form', () => {
it('is validated', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
// at first both input elements are invalid
cy.get('input:invalid').should('have.length', 2)

cy.log('**enter the item**')
cy.get('#item').type('push pin')
cy.get('input:invalid').should('have.length', 1)

cy.log('**enter quantity**')
cy.get('#quantity').type(3)
cy.get('input:invalid').should('have.length', 0)
// instead both items should be valid
// plus the submit input button
cy.get('input:valid').should('have.length', 3)
})
})
})

Notice that the input elements satisfying its rules get pseudo-class :valid. At the end of our test, all input elements have the pseudo-class :valid, including the submit button.

First test checking :invalid and :valid pseudo-classes

checkValidity

Unfortunately, we cannot check :invalid or :valid pseudo-class by using an assertion, since it is not a declared class.

1
2
3
4
5
6
7
8
9
10
11
/// <reference types="cypress" />
describe('element', () => {
it('validity', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
// ⛔️ DOES NOT WORK
cy.get('#item').should('have.class', ':invalid')
})
})
})

Cannot assert :invalid class presence

Instead we can call a method checkValidity() on an HTML element asking if it is valid. We can call this method from the DevTools console:

Check element's validity

Anything we can do from the console, we can do from a Cypress test. Unfortunately, since Cypress returns jQuery element, we cannot simply invoke the method

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <reference types="cypress" />
describe('element', () => {
it('validity', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
// ⛔️ DOES NOT WORK
// cy.get('#item').should('have.class', ':invalid')
// ⛔️ DOES NOT WORK
cy.get('#item').invoke('checkValidity')
})
})
})

Cannot invoke checkValidity using jQuery

Instead we must call the method on the original HTML element:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <reference types="cypress" />
describe('element', () => {
it('validity', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
// ⛔️ DOES NOT WORK
// cy.get('#item').should('have.class', ':invalid')
// ⛔️ DOES NOT WORK
// cy.get('#item').invoke('checkValidity')
// ✅ WORKS
cy.get('#item').then($el => $el[0].checkValidity()).should('be.false')
cy.get('#item').type('paper')
.then($el => $el[0].checkValidity()).should('be.true')
})
})
})

Hmm, not sure why the boolean is reported twice in the Command Log!

The input element becomes valid after typing

Tip: you can check form's validity too!

Checking the entire form's validity

validationMessage

The error displayed by the browser is specific to the validation rule. We can retrieve it from the Console.

Validation message changes as the input changes

Let's confirm it by test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <reference types="cypress" />
describe('element', () => {
it('validationMessage', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
cy.get('#quantity').invoke('prop', 'validationMessage')
.should('equal', 'Please fill out this field.')
cy.get('#quantity').type(20)
cy.get('#quantity').invoke('prop', 'validationMessage')
.should('equal', 'Value must be less than or equal to 5.')
cy.get('#quantity').clear().type(3)
cy.get('#quantity').invoke('prop', 'validationMessage')
.should('equal', '')
})
})
})

Validation message test

validity

Checking the validation message only retrieves the single message shown to the user, what about all validation rules? Turns out we can retrieve the full validation status by looking up another property. For example, the quantity input element cannot have numbers below 1.

Full validity

Let's test it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <reference types="cypress" />
describe('element', () => {
it('full validity', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
cy.get('#quantity').invoke('prop', 'validity')
.should('deep.equal', {
valueMissing: true,
typeMismatch: false,
patternMismatch: false,
tooLong: false,
tooShort: false,
rangeUnderflow: false,
rangeOverflow: false,
stepMismatch: false,
badInput: false,
customError: false,
valid: false
})
})
})
})

Oops, does not work, even though the objects do look the same.

Deep equality fails to match the objects

Turns out, the objects are different because the Element.validity() returns ValidityState that is different from a plain object.

Inspect objects from the assertion

The simplest way to compare this object is to use deep.include assertion.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/// <reference types="cypress" />
describe('element', () => {
it('full validity', () => {
cy.visit('index.html')

cy.get('#form-validation').within(() => {
cy.get('#quantity').invoke('prop', 'validity')
.should('deep.include', {
valueMissing: true,
typeMismatch: false,
patternMismatch: false,
tooLong: false,
tooShort: false,
rangeUnderflow: false,
rangeOverflow: false,
stepMismatch: false,
badInput: false,
customError: false,
valid: false
})
cy.get('#quantity').type('-1')
.invoke('prop', 'validity')
.should('deep.include', {
valueMissing: false,
typeMismatch: false,
patternMismatch: false,
tooLong: false,
tooShort: false,
rangeUnderflow: true,
rangeOverflow: false,
stepMismatch: false,
badInput: false,
customError: false,
valid: false
})
cy.get('#quantity').clear().type('3')
.invoke('prop', 'validity')
.should('deep.include', {
valueMissing: false,
typeMismatch: false,
patternMismatch: false,
tooLong: false,
tooShort: false,
rangeUnderflow: false,
rangeOverflow: false,
stepMismatch: false,
badInput: false,
customError: false,
valid: true
})
})
})
})

Deep include works very well

Form is not submitted

Finally, let's confirm the form is not submitted until the inputs are valid. Let's test this - we will catch the form submission by attaching our own event handler from the test - after all, Cypress tests run right next to your application, so we can do anything we want.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// <reference types="cypress" />
describe('form', () => {
it('is not submitted without quantity', () => {
cy.visit('index.html')

// if the browser tries to submit the form
// we should fail the test
cy.get('#form-validation').invoke('submit', (e) => {
// do not actually submit the form
e.preventDefault()
// fail this test
throw new Error('submitting!!!')
})

cy.get('#form-validation').within(() => {
cy.get('#item').type('paper')
cy.get('input[type=submit]').click()
})
})
})

When this test runs, the browser pops its error validation message, but does not submit the form.

Does not submit

Just for fun, let's confirm the test does fail if the browser submits the form

1
2
3
4
5
6
// same test but let's enter quantity
cy.get('#form-validation').within(() => {
cy.get('#item').type('paper')
cy.get('#quantity').type(3)
cy.get('input[type=submit]').click()
})

Our test catches it

Test catches form submission

We should change the test back, and add another test to make sure the form is submitted when there are no validation errors.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
it('is submitted when valid', () => {
cy.visit('index.html')

let submitted
cy.get('#form-validation').invoke('submit', (e) => {
// do not actually submit the form
e.preventDefault()
submitted = true
})

cy.get('#form-validation').within(() => {
cy.get('#item').type('paper')
cy.get('#quantity').type(3)
cy.get('input[type=submit]').click()
})
.then(() => {
expect(submitted, 'form submitted').to.be.true
})
})

Testing successful form submission

💡 You can also intercept the form submission using cy.intercept command, see the cy.intercept Recipe

Bonus 1: Match CSS class ":valid" and ":invalid"

You can also check if the input field is valid or invalid by matching the pseudo classes.

1
2
3
4
5
6
7
8
// the input field is invalid
cy.get('#quantity').should('match', ':invalid')
// type the right value
cy.get('#quantity').clear().type('4')
// the quantity input is valid
cy.get('#quantity')
.should('match', ':valid')
.and('not.match', ':invalid')