Testing React Number Format Component Example

Writing E2E and component tests for react-number-format component.

React library react-number-format is a powerful component for showing formatted input fields, like phone numbers and money amounts. I have created a small Vite.js example in the repo bahmutov/react-number-format-example to show how these components can be tested using Cypres.io test runner.

Here is the JSX markup for the above page.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { PatternFormat, NumericFormat } from 'react-number-format'

<PatternFormat
format="+1 (###) ### ####"
allowEmptyFormatting
mask="_"
data-cy="phone"
/>
<br />
<NumericFormat
value={1234}
prefix="$"
allowNegative={false}
decimalScale={2}
inputMode="decimal"
onValueChange={(values, sourceInfo) => {
console.log(values, sourceInfo)
}}
data-cy="price"
/>

Simple, isn't it? The entered text is formatted according to the props. The formatted values are set as the current value attribute of the input elements.

Formatted values

Let's confirm the application is working as expected and indeed formats the phone number and the dollar amount.

End-to-end test

First, I will write an end-to-end test. I install Cypress and cypress-real-events because nothing is real without it.

1
2
3
$ npm i -D cypress cypress-real-events
+ [email protected]
+ [email protected]
cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// https://github.com/dmtrKovalenko/cypress-real-events
import 'cypress-real-events'

it('enters phone number and dollar amount', () => {
cy.visit('/')
cy.get('[data-cy=phone]')
.type('2345678900')
// cy.realPress command comes from cypress-real-events
.realPress('Tab')
// confirm the "Tab" press moves the focus to the price input field
cy.focused().should('have.attr', 'data-cy', 'price').clear().type('18.99')
cy.log('**confirm formatted values**')
cy.get('[data-cy=phone]').should('have.value', '+1 (234) 567 8900')
cy.get('[data-cy=price]').should('have.value', '$18.99')
})

The test enters raw text and verifies the react-number-format logic formats the values correctly.

Unhappy tests

The react-number-format components have a lot of features. We might need to investigate how a component behaves if the user tries to enter invalid information. For example, the price input field should ignore multiple ., not allow entering negative numbers, and strip the leading zeroes. Let's write a test to confirm it.

cypress/e2e/spec.cy.js
1
2
3
4
it('ignores invalid characters in price input', () => {
cy.visit('/')
cy.get('[data-cy=price]').clear().type('-012.345.678')
})

Notice a curious behavior: the price input ignores the duplicate . and all digits after the two decimals. But it does not strip the leading zero until the element loses its focus.

Let's describe this behavior in our test to avoid accidentally changing it in the future. We can use cy.blur command to remove focus from an element. Since we are working with the same DOM element <input data-cy=price ... /> we can simply chain our commands and assertions into a single chain.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
it('ignores invalid characters in price input', () => {
cy.visit('/')
cy.get('[data-cy=price]')
.clear()
.type('-012.345.678')
.should('have.value', '$012.34')
.blur()
.should('have.value', '$12.34')
})

Another unhappy test could verify the phone number input formatter removes all characters but 10 digits.

1
2
3
4
5
6
7
it('ignores invalid characters in phone input', () => {
cy.visit('/')
cy.get('[data-cy=phone]')
.type('-phone 123/456-7890.1234')
// all characters but 10 digit phone are removed
.should('have.value', '+1 (123) 456 7890')
})

Formatted phone number input test

Beautiful.

Component tests

End-to-End tests are powerful. They check the entire user flow. But what about learning how a component works? In our app we are using the `` prop that should notify the parent component about the formatted user values. Does it work? Is it safe to upgrade the react-number-format version?

1
2
3
4
5
6
7
8
9
10
11
<NumericFormat
value={1234}
prefix="$"
allowNegative={false}
decimalScale={2}
inputMode="decimal"
onValueChange={(values, sourceInfo) => {
console.log(values, sourceInfo)
}}
data-cy="price"
/>

Here is where the component tests come into play. Let's configure Cypress component testing; there is nothing to install, since it uses Vite.js and React already used by the application itself.

Step 1: change from E2E to Component testing type

Picking the component testing type

Step 2: confirm the detected framework and bundler options

Confirm the detected options

Step 3: create a new component spec

Create a component spec

Here is my first test. It simply mounts the component to play with.

cypress/component/onValueChange.cy.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { NumericFormat } from 'react-number-format'
import '../../src/App.css'

describe('onValueChange.cy.jsx', () => {
it('playground', () => {
cy.mount(
<NumericFormat
value={1234}
prefix="$"
allowNegative={false}
decimalScale={2}
inputMode="decimal"
onValueChange={(values, sourceInfo) => {
console.log(values, sourceInfo)
}}
data-cy="price"
/>,
)
})
})

Mounted NumericFormat component

In the component tests we can simply pass a Cypress Sinon stub function as the onValueChange prop.

cypress/component/onValueChange.cy.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { NumericFormat } from 'react-number-format'
import '../../src/App.css'

describe('onValueChange.cy.jsx', () => {
it('playground', () => {
cy.mount(
<NumericFormat
value={1234}
prefix="$"
allowNegative={false}
decimalScale={2}
inputMode="decimal"
onValueChange={cy.stub().as('onValueChange')}
data-cy="price"
/>,
)
// work with the component as if it were a web app
cy.get('[data-cy=price]').clear().type('50.99')
})
})

We can see the stub function called 6 times. The first call is when we cleared the input element using cy.clear command. Then we types 5 characters: '5', '0', '.', '9', and '9'. For each character, the component called our onValueChange prop. Let's confirm each call.

cypress/component/onValueChange.cy.jsx
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
54
55
56
57
58
59
60
61
62
63
64
import { NumericFormat } from 'react-number-format'
import '../../src/App.css'

describe('onValueChange.cy.jsx', () => {
it('playground', () => {
cy.mount(
<NumericFormat
value={1234}
prefix="$"
allowNegative={false}
decimalScale={2}
inputMode="decimal"
onValueChange={cy.stub().as('onValueChange')}
data-cy="price"
/>,
)
// work with the component as if it were a web app
cy.get('[data-cy=price]').clear()
cy.get('@onValueChange')
.should('have.been.calledOnceWith', {
formattedValue: '',
value: '',
floatValue: undefined,
})
.invoke('resetHistory')
cy.get('[data-cy=price]').type('50.99')
// check each call
cy.get('@onValueChange').should('have.property', 'callCount', 5)
cy.get('@onValueChange')
.its('firstCall.args.0')
.should('deep.equal', { formattedValue: '$5', value: '5', floatValue: 5 })
cy.get('@onValueChange').its('secondCall.args.0').should('deep.equal', {
formattedValue: '$50',
value: '50',
floatValue: 50,
})
cy.get('@onValueChange').its('thirdCall.args.0').should('deep.equal', {
formattedValue: '$50.',
value: '50.',
floatValue: 50,
})
cy.get('@onValueChange').its('thirdCall.args.0').should('deep.equal', {
formattedValue: '$50.',
value: '50.',
floatValue: 50,
})
cy.get('@onValueChange')
.invoke('getCall', 3)
.its('args.0')
.should('deep.equal', {
formattedValue: '$50.9',
value: '50.9',
floatValue: 50.9,
})
cy.get('@onValueChange')
.invoke('getCall', 4)
.its('args.0')
.should('deep.equal', {
formattedValue: '$50.99',
value: '50.99',
floatValue: 50.99,
})
})
})

It is a little bit verbose, but we are checking quite a few separate calls.

Checking each onValueChange call

We can make it shorter using cypress-map to extract all first arguments at once.

cypress/component/onValueChange.cy.jsx
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
54
55
56
57
58
import { NumericFormat } from 'react-number-format'
import '../../src/App.css'
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

describe('onValueChange.cy.jsx', () => {
it('calls onValueChange', () => {
cy.mount(
<NumericFormat
value={1234}
prefix="$"
allowNegative={false}
decimalScale={2}
inputMode="decimal"
onValueChange={cy.stub().as('onValueChange')}
data-cy="price"
/>,
)
// work with the component as if it were a web app
cy.get('[data-cy=price]').clear()
cy.get('@onValueChange')
.should('have.been.calledOnceWith', {
formattedValue: '',
value: '',
floatValue: undefined,
})
.invoke('resetHistory')
cy.get('[data-cy=price]').type('50.99')
// check each call
cy.get('@onValueChange').should('have.property', 'callCount', 5)
cy.get('@onValueChange')
.invoke('getCalls')
.map('args.0')
.should('deep.equal', [
{ formattedValue: '$5', value: '5', floatValue: 5 },
{
formattedValue: '$50',
value: '50',
floatValue: 50,
},
{
formattedValue: '$50.',
value: '50.',
floatValue: 50,
},
{
formattedValue: '$50.9',
value: '50.9',
floatValue: 50.9,
},
{
formattedValue: '$50.99',
value: '50.99',
floatValue: 50.99,
},
])
})
})

Check the arguments from all calls at once

It is up to you to decide which test variant is more readable.

Bonus 1: use cy-spok to check multiple items

We can simplify the above test. By using cy-spok assertion we can check properties from multiple objects in an array of calls at once. Here is the relevant assertion:

cypress/component/cy-spok-example.cy.jsx
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
// https://github.com/bahmutov/cy-spok
import spok from 'cy-spok'

cy.get('[data-cy=price]').type('50.99')
// check each call
cy.get('@onValueChange')
.invoke('getCalls')
.map('args.0')
.should(
spok([
{ formattedValue: '$5', value: '5', floatValue: 5 },
{
formattedValue: '$50',
value: '50',
floatValue: 50,
},
{
formattedValue: '$50.',
value: '50.',
floatValue: 50,
},
{
formattedValue: '$50.9',
value: '50.9',
floatValue: 50.9,
},
{
formattedValue: '$50.99',
value: '50.99',
floatValue: 50.99,
},
]),
)

Check the arguments from all calls using cy-spok assertion

I think it looks nicer than the deep.equal or individual assertions.

📺 You can watch me code this example in the video Verify Cypress Component Prop Calls Using Stubs, cypress-map, and cy-spok.