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.
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.
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.
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 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.
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)
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 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.
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.
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') })
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.
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.
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.