Split a very long Cypress test into shorter ones using App Actions

October 29, 2019

•

By Gleb Bahmutov

Imagine a very long-running end-to-end test. For me, anything longer than 20 seconds seems like way too long of a test to effectively work with. When running on Continuous Integration server, a long test might even run out of memory, crashing the Test Runner. How can we split a longer test into a series of independent short tests without losing the test's parts?

Example application

My example application is called cypress-example-forms and it has a multi-page form. The user needs to fill 3 pages of fields before clicking the "Sign up" button - this is similar to what many booking and registration web applications perform. Here is the video recording of the test; I have deliberately set the cy.type speed to be a little bit slow on purpose to better show the steps.

A single long test going through 3 pages of forms before finishing

Let's take a look at the application's implementation. Every property filled by the user (or by the Cypress Test Runner during an end-to-end test) updates the application's state object.

// any input field can send "onchange" event
// which just sets the corresponding property in the state object
handleChange = event => {
  const { name, value } = event.target
  this.setState({
    [name]: value
  })
}
const Field = props => {
  return (
    <React.Fragment>
      <label htmlFor={props.name}>Field {props.name}</label>
      <input
        className='form-control'
        id={props.name}
        name={props.name}
        type='text'
        placeholder={'Enter value for ' + props.name}
        value={props.field1}
        onChange={props.handleChange}
      />
    </React.Fragment>
  )
}
// inside a form each field is rendered like this
<div className='form-group'>
  <Field name='first' handleChange={props.handleChange} />
  <Field name='last' handleChange={props.handleChange} />
  <Field name='email' handleChange={props.handleChange} />
  // more fields
</div>

Splitting the long test

Let's look at the application's state after the test fills the fields on the first page, clicks "Next" button and transitions to the second page. First, I will add the following to the React application's constructor to save its reference on the window object.

class MasterForm extends React.Component {
  constructor (props) {
    super(props)
    // only expose the app during E2E tests
    if (window.Cypress) {
      window.app = this
    }
  }
  ...
}

Let's see how we can use the window.app to inspect the application's state. Here is the long test paused on the second page.

/// <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
    .pause()
})
The test has filled the first page and has transitioned to the second page

While the test is paused, open the DevTools console, switch the context to "Your App: ..." iframe and type window.app.state. It should return the current application's state object, showing every field we have filled, plus currentStep: 2 since we are on the second page of the form.

Inspecting the app's state object right from the DevTools console via "window.app" reference

Nice, this is how the application's state should be after filling the first page and going to the second page. Let's write a new shorter test to confirm that the first page works. We can copy the window.app.state object from the DevTools console - we will use it in our test.

Use the copy command available in DevTools to copy the entire object to Clipboard

The test we will write simply fills the first page, confirms the transition to the second page and then checks the application's state object. I will place this test in spec file cypress/integration/three-tests.js

/// <reference types="cypress" />
const typeOptions = { delay: 35 }

describe('3 shorter tests', () => {
  // we have copied this state object from DevTools console
  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.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')
    cy.window()
      .its('app.state')
      .should('deep.equal', startOfSecondPageState)
  })
})

In the above test we start on the first page, fill the input fields (just like before), confirm that we are on the second page - and then compare the entire application's state object against what we think it should be.

// the end of the first test
cy.log('Second page')
cy.contains('h1', 'Book Hotel 2')
cy.window()
  .its('app.state')
  .should('deep.equal', startOfSecondPageState)
First test ending with state object check

Test the second page

Let's think about the second page - how does the application "know" that it should render the page with the second form? In the application's code it simply does this:

<Step1
  currentStep={this.state.currentStep}
  handleChange={this.handleChange}
  email={this.state.email}
/>
<Step2
  currentStep={this.state.currentStep}
  handleChange={this.handleChange}
  username={this.state.username}
/>
function Step1 (props) {
  if (props.currentStep !== 1) {
    return null
  }
  // render input fields for page 1
}
function Step2 (props) {
  if (props.currentStep !== 2) {
    return null
  }
  // render input fields for page 2
}

The application looks at the state.currentStep value and picks form to render. So if we set the property state.currentStep = 2 at the start of the test ... our application will render the second page form. Even better - if we set the entire state object using the copied object startOfSecondPageState our application will behave as if the user has filled the first page through the UI, then clicked "Next" button and then transitioned to the second page "normally". To set the state object in React we need to call app.setState which we will do in the second test below.

it('second page', () => {
  cy.window()
    .its('app')
    .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)
  ...
  // the rest of the input fields
})

The second page test runs and it should finish just like the first page test - by asserting the application's state is as expected. We can copy the state object from the DevTools console, or combine the previous state with the new fields we have just filled.

const startOfSecondPageState = {...}
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'
}
it('second page', () => {
  cy.window()
    .its('app')
    .invoke('setState', startOfSecondPageState)
  // fill fields
  cy.log('Third page')
  cy.contains('h1', 'Book Hotel 3')

  cy.window()
    .its('app.state')
    .should('deep.equal', startOfThirdPageState)
})

The test runs and finishes on the third page.

The test starts right at the second page and finishes on the third.

Test the third page

We have written two tests - one for each page. Let's finish with a test that only covers the third page. Again, it will start immediately at the third page by setting the application's state.

it('third page', () => {
  cy.window()
    .its('app')
    .invoke('setState', startOfThirdPageState)

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

There is no need to confirm the state after the clicking the "Sign up" button (unless you want to) - we are checking the UI state using cy.contains command instead. Again, the test starts immediately on the third page.

Third page test

Taken together these 3 tests run in the same or almost the same time as the single combined test - but they allow you to iterate quickly on each page of the application's flow. Each test is independent from the others - you can run the tests in any order, or a single test at a time.

You can find the source code at https://github.com/bahmutov/cypress-example-forms.

See also

We call directly accessing the application's instance and invoking methods like app.setState "App Actions", and you can read more about this approach in the blog post Stop using Page Objects and Start using App Actions. We are sure that you can expose an application's methods in any framework, and we have written examples for Vue applications and Angular applications.