Control React Applications From Cypress Tests

How to access and change the internal component state from end-to-end tests using cypress-react-app-actions.

In the previous blog post Access React Components From Cypress E2E Tests I have shown how the test code could get to the React component's internals, similar to what the React DevTools browser extension does. In this blog post, I will show how to use this approach to drastically speed up end-to-end tests. The idea is to control the application by setting its internal state rather than using the page UI in every test. We will split a single long test into individual tests, each starting the app where it needs it to be in an instant, rather than going through already tested UI commands. It is similar to what I have shown a long time ago in the blog post Split a very long Cypress test into shorter ones using App Actions. But the approach described in this blog post does not need any modifications to the application code, which is a big deal.

A single long test

Imagine our application contains several forms to fill. The test has to fill the first page before the second page appears. Once the second page is filled, the third page is shown. After filling the third page, the form is submitted and the test is done.

cypress/integration/single-test.js
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
/// <reference types="cypress" />
const typeOptions = { delay: 35 }

it('books hotel (all pages)', () => {
cy.visit('/')

cy.log('**First page**')
cy.contains('h1', 'Book Hotel 1')

cy.get('#first').type('Joe', typeOptions)
cy.get('#last').type('Smith', typeOptions)
cy.get('#email').type('[email protected]', typeOptions)

cy.get('#field1a').type('Field 1a text value', typeOptions)
cy.get('#field1b').type('Field 1b text value', typeOptions)
cy.get('#field1c').type('Field 1c text value', typeOptions)
cy.get('#field1d').type('Field 1d text value', typeOptions)
cy.get('#field1e').type('Field 1e text value', typeOptions)

cy.contains('Next').click()

cy.log('**Second page**')
cy.contains('h1', 'Book Hotel 2')
// we are on the second page

cy.get('#username').type('JoeSmith', typeOptions)
cy.get('#field2a').type('Field 2a text value', typeOptions)
cy.get('#field2b').type('Field 2b text value', typeOptions)
cy.get('#field2c').type('Field 2c text value', typeOptions)
cy.get('#field2d').type('Field 2d text value', typeOptions)
cy.get('#field2e').type('Field 2e text value', typeOptions)
cy.get('#field2f').type('Field 2f text value', typeOptions)
cy.get('#field2g').type('Field 2g text value', typeOptions)
cy.contains('Next').click()

cy.log('**Third page**')
cy.contains('h1', 'Book Hotel 3')

cy.get('#field3a').type('Field 3a text value', typeOptions)
cy.get('#field3b').type('Field 3b text value', typeOptions)
cy.get('#field3c').type('Field 3c text value', typeOptions)
cy.get('#field3d').type('Field 3d text value', typeOptions)
cy.get('#field3e').type('Field 3e text value', typeOptions)
cy.get('#field3f').type('Field 3f text value', typeOptions)
cy.get('#field3g').type('Field 3g text value', typeOptions)
cy.contains('button', 'Sign up').click()

cy.contains('button', 'Thank you')
})

The above test takes almost 19 seconds to finish. Of course, it is the slowest end-to-end test in the world, but you have to sit and wait for it, even if you are only interested in changing how it tests the form submission for example.

A single test going through all the page steps

The app state after the first page

All the fields we fill on the first page go into the internal state of the application. The application creates a form for each page and passes the change handler function as a prop.

index.js
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
import Step1 from './Step1.jsx'

handleChange = (event) => {
const { name, value } = event.target
this.setState({
[name]: value,
})
}

handleSubmit = (event) => {
event.preventDefault()

console.log('submitting state', this.state)

const { email, username } = this.state

this.setState({
submitted: true,
})

alert(`Your registration detail: \n
Email: ${email} \n
Username: ${username}`)
}

<Step1
currentStep={this.state.currentStep}
handleChange={this.handleChange}
email={this.state.email}
/>
<Step2
currentStep={this.state.currentStep}
handleChange={this.handleChange}
username={this.state.username}
/>
<Step3
currentStep={this.state.currentStep}
handleChange={this.handleChange}
password={this.state.password}
submitted={this.state.submitted}
/>

Thus we can validate that the Step1 component is working correctly by checking the state after we fill the form through the page.

cypress/integration/actions.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
beforeEach(() => {
cy.visit('/')
})

it('first page', () => {
cy.log('**First page**')
cy.contains('h1', 'Book Hotel 1')

cy.get('#first').type('Joe', typeOptions)
cy.get('#last').type('Smith', typeOptions)
cy.get('#email').type('[email protected]', typeOptions)

cy.get('#field1a').type('Field 1a text value', typeOptions)
cy.get('#field1b').type('Field 1b text value', typeOptions)
cy.get('#field1c').type('Field 1c text value', typeOptions)
cy.get('#field1d').type('Field 1d text value', typeOptions)
cy.get('#field1e').type('Field 1e text value', typeOptions)

cy.contains('Next').click()

cy.log('Second page')
cy.contains('h1', 'Book Hotel 2')
})

The first page filled by the test

We are testing the page just like a human user would - by going to each input field and typing text. Once the fields are filled, we click the button "Next" and check if we end up on the second page. But how do we check if the values we typed really were stored correctly by the application?

By getting access to the application state through React internals. I wrote cypress-react-app-actions plugin that gets to the React component from a DOM element, similar to how React DevTools browser extension works.

1
2
$ npm i -D cypress-react-app-actions
+ [email protected]

We should import the plugin from our spec or from the support file

1
2
3
// https://github.com/bahmutov/cypress-react-app-actions
import 'cypress-react-app-actions'
// now we can use the child command .getComponent()

Let's see what fields the component has at the end of the test above.

1
2
3
4
5
6
cy.log('Second page')
cy.contains('h1', 'Book Hotel 2')
cy.get('form')
.getComponent()
.its('state')
.then(console.log)

The application state object after finishing step one

Tip: you can see all component fields and methods by printing it to the console with cy.get('form').getComponent().then(console.log) command.

The React component

The component's state should always include the field values we have typed, so let's verify this. We could use "deep.equal" or "deep.include" assertion, or even cy-spok here.

cypress/integration/actions.js
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
const startOfSecondPageState = {
currentStep: 2,
email: '[email protected]',
field1a: 'Field 1a text value',
field1b: 'Field 1b text value',
field1c: 'Field 1c text value',
field1d: 'Field 1d text value',
field1e: 'Field 1e text value',
first: 'Joe',
last: 'Smith',
username: '',
}

beforeEach(() => {
cy.visit('/')
})

it('first page', () => {
...
cy.contains('Next').click()

cy.log('Second page')
cy.contains('h1', 'Book Hotel 2')
cy.get('form')
.getComponent()
.its('state')
.should('deep.equal', startOfSecondPageState)
})

The internal state is always the same after the first page is finished

The second page

Now let's verify that we can fill the second page of the form. In order to get to the second page, we need to fill the form on the first page. Hmm, we know it works, so repeating the same page commands does not give us any more confidence in our application. It just slows down the second test. What we can do instead is to set the application to the state after the first page is filled. We know this state - we have verified it at the end of the first test.

1
2
3
4
5
// the end of the first test
cy.get('form')
.getComponent()
.its('state')
.should('deep.equal', startOfSecondPageState)

Thus we can set the app's state to the object startOfSecondPageState and the application will behave as if we went through the form, filling it by typing. It is the same application behavior.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
beforeEach(() => {
cy.visit('/')
})

it('second page', () => {
cy.get('form').getComponent().invoke('setState', startOfSecondPageState)

cy.log('**Second page**')
cy.contains('h1', 'Book Hotel 2')
// start filling input fields on page 2
cy.get('#username').type('JoeSmith', typeOptions)
cy.get('#field2a').type('Field 2a text value', typeOptions)
cy.get('#field2b').type('Field 2b text value', typeOptions)
cy.get('#field2c').type('Field 2c text value', typeOptions)
cy.get('#field2d').type('Field 2d text value', typeOptions)
cy.get('#field2e').type('Field 2e text value', typeOptions)
cy.get('#field2f').type('Field 2f text value', typeOptions)
cy.get('#field2g').type('Field 2g text value', typeOptions)
cy.contains('Next').click()

cy.log('Third page')
cy.contains('h1', 'Book Hotel 3')
})

Testing the second page by starting the application in the known state

Beautiful. How does the application finish? Again - it has a certain internal state we can verify.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const startOfThirdPageState = {
...startOfSecondPageState,
currentStep: 3,
username: 'JoeSmith',
field2a: 'Field 2a text value',
field2b: 'Field 2b text value',
field2c: 'Field 2c text value',
field2d: 'Field 2d text value',
field2e: 'Field 2e text value',
field2f: 'Field 2f text value',
field2g: 'Field 2g text value',
}
...
cy.log('Third page')
cy.contains('h1', 'Book Hotel 3')
cy.get('form')
.getComponent()
.its('state')
.should('deep.equal', startOfThirdPageState)

The third page

We similarly start the third test to verify we can fill the form on the third page. We set the state to the same state object the second test has finished with. Even better - we know the user will submit the form, so we can spy on the component's method handleSubmit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('third page', () => {
cy.get('form')
.getComponent()
.then((comp) => {
cy.spy(comp, 'handleSubmit').as('handleSubmit')
})
.invoke('setState', startOfThirdPageState)

cy.log('**Third page**')
cy.contains('h1', 'Book Hotel 3')
...
cy.contains('button', 'Sign up').click()
cy.contains('button', 'Thank you')

cy.get('form').parent().getComponent().its('state').should('deep.include', {
submitted: true,
username: 'JoeSmith',
})

// the spy is called once
cy.get('@handleSubmit').should('be.calledOnce')
})

The third test verifies the form was submitted

It is up to the developer to decide which application internal properties to verify.

Invoking app actions

We can verify the internal application state and we can call the component's methods. For example, we can call the form's submit method ourselves.

src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
handleSubmit = (event) => {
event.preventDefault()

const { email, username } = this.state

this.setState({
submitted: true,
})

alert(`Your registration detail: \n
Email: ${email} \n
Username: ${username}`)
}
cypress/integration/actions.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
it('submits the form', () => {
cy.get('form').getComponent().invoke('setState', beforeSubmitState)
cy.window().then((win) => cy.spy(win, 'alert').as('alert'))
cy.get('form')
.getComponent()
.invoke('handleSubmit', {
preventDefault: cy.spy().as('preventDefault'),
})
// check the UI
cy.contains('button', 'Thank you').should('be.visible')
// check the application's behavior
cy.get('@preventDefault').should('have.been.calledOnce')
// the alert message includes the username and the email
cy.get('@alert')
.should('have.been.calledOnce')
.its('firstCall.args.0')
.should('include', beforeSubmitState.email)
.and('include', beforeSubmitState.username)
// verify the form's state changes
cy.get('form')
.getComponent()
.its('state')
.should('have.property', 'submitted', true)
})

We are verifying the application's behavior during the submit action.

Invoking the submit application action and testing the behavior and the state changes

Not only the last test is powerful and gives us insight into the application's behavior - it is also fast. The original single test took 19 seconds to finish filling the form and submitting it. The focused test "submits the form" above finished in 190ms which is 100 times faster.

Video

I have recorded a video showing the main points of this blog post. Watch at below.