ui-testing-best-practices

Cypress + Storybook. Keeping test scenario, data and component rendering in one place.

Russian version: Cypress + Storybook. Хранение тестового сценария, данных и рендеринг компонента в одном месте.

One Paragraph Explainer

Many of us have chosen Cypress as a tool to test components hosted via Storybook/Styleguidist/Docz. @NoriSte’s example suggests creating some Storybook Stories, put components there and expose important data to the global variable in order to have access to the test. The nice approach actually, but the test becomes broken into the pieces between Storybook and Cypress.

Here I’d like to show how to go a little bit further and get the most out of executing JavaScript in Cypress. To see it in action, you may download the source code and execute then npm i and npm test in the console.

The task

Imagine that we are writing an adaptor for existing Datepicker component to use it across all company websites. We don’t want to accidentally break anything, so we have to cover it by tests.

Storybook

All we need from Storybook - an empty Story that saves a reference to the testing component in the global variable. In order not to be so useless this Story renders the single DOM node. This node will be our war zone inside the test.

import React from 'react'
import Datepicker from './Datepicker.jsx'

export default {
  component: Datepicker,
  title: 'Datepicker',
}

export const emptyStory = () => {
  // Reference to retrieve it in Cypress during the test
  window.Datepicker = Datepicker

  // Just a mount point
  return <div id="component-test-mount-point"></div>
}

Okay, we’ve finished with Storybook. Let’s take a look at Cypress.

Cypress

Personally, I like to get started with test cases enumeration. Seems we have next test structure:

/// <reference types="cypress" />

import React from 'react'
import ReactDOM from 'react-dom'

/**
 * <Datepicker />
 * * renders text field.
 * * renders desired placeholder text.
 * * renders chosen date.
 * * opens calendar after clicking on text field.
 */

context('<Datepicker />', () => {
  it('renders text field.', () => {})

  it('renders desired placeholder text.', () => {})

  it('renders chosen date.', () => {})

  it('opens calendar after clicking on text field.', () => {})
})

Fine. We have to run this test in any environment. Open the Storybook, go directly to the empty Story by clicking at “Open canvas in new tab” button in the sidebar. Copy that URL and make Cypress visit it:

const rootToMountSelector = '#component-test-mount-point'

before(() => {
  cy.visit('http://localhost:12345/iframe.html?id=datepicker--empty-story')
  cy.get(rootToMountSelector)
})

As you may guess, in order to test we are going to render all components states in the same <div> with id=component-test-mount-point. So that the tests do not affect each other, we must unmount any component here before the next test execution. Let’s add some cleanup code:

afterEach(() => {
  cy.document().then((doc) => {
    ReactDOM.unmountComponentAtNode(doc.querySelector(rootToMountSelector))
  })
})

Now we are ready to complete the test. Retrieve the component reference, render the component and make some assertions:

const selectors = {
  innerInput: '.react-datepicker__input-container input',
}

it('renders text field.', () => {
  cy.window().then((win) => {
    ReactDOM.render(<win.Datepicker />, win.document.querySelector(rootToMountSelector))
  })

  cy.get(selectors.innerInput).should('be.visible')
})

Do you see that? Nothing stops us from passing any props or data to the component directly! It’s all in one place - in Cypress!

Testing in a few steps with wrapper

Sometimes we’d like to test that component behaves predictably according to changing props. Examine <Popup /> component with showed props. When showed is true, <Popup /> is visible. After that, changing showed to false, <Popup /> should become hidden. How to test that transition?

Those problems are easy to handle in an imperative way, but in case of declarative React we need to come up with something. In our team, we use an additional wrapper component with state to handle it. The state here is boolean, it responses to “showed” props.

let setPopupTestWrapperState = null
const PopupTestWrapper = ({ showed, win }) => {
  const [isShown, setState] = React.useState(showed)
  setPopupTestWrapperState = setState
  return <win.Popup showed={isShown} />
}

Now we are about to finish the test:

it('becomes hidden after being shown when showed=false passed.', () => {
  // arrange
  cy.window().then((win) => {
    // initial state - popup is visible
    ReactDOM.render(
      <PopupTestWrapper showed={true} win={win} />,
      win.document.querySelector(rootToMountSelector)
    )
  })

  // act
  cy.then(() => {
    setPopupTestWrapperState(false)
  })

  // assert
  cy.get(selectors.popupWindow).should('not.be.visible')
})

Tip: If such hook hasn’t worked or you dislike calling the hook outside the component - rewrite the wrapper via simple class.

Testing component methods

Actually, I’ve never written such a test. The idea has come up while writing this article. Probably it may be useful to test a component in a unit test style.

However, you may easily do it in Cypress. Just create a ref to the component before rendering. It is worth mentioning that the ref gives access to state and other elements of the component.

I’ve added the hide method to <Popup \> which makes it hidden forcibly (example for the sake of example). The following test looks like this:

it('closes via method call.', () => {
  // arrange
  let popup = React.createRef()
  cy.window().then((win) => {
    // initial state - popup is visible
    ReactDOM.render(
      <win.Popup showed={true} ref={popup} />,
      win.document.querySelector(rootToMountSelector)
    )
  })

  // act
  cy.then(() => {
    popup.current.hide()
  })

  // assert
  cy.get(selectors.popupWindow).should('not.be.visible')
})

To sum it up: the roles of each participant

Storybook:

Pro-Tip: Please, run another instance of Storybook for your component library or pages.

Cypress:

Conclusion

Here I’d like to express my personal opinion and my colleagues’ position about possible questions that may appear during the reading. Written below doesn’t pretend to be true, may differ from reality and contain nuts.

My test utils use js-dom under the hood. Do I limit myself?

Should I choose the approach suggested?

Why not use cypress-react-unit-test then? Why do we need Storybook?

Probably, it is our future to test components. There will be no need to maintain a separate instance of the Storybook, all tests will be entirely under the responsibility of Cypress, the configuration will be simplified, etc. But now the tool has some problems that make the environment provided incomplete for running tests. Hope that Gleb Bahmutov and the Cypress team will make it worked 🤞

Crossposted by daedalius on Medium and on habr.com (in Russian).