DEV Community

loading...

12 Recipes for testing React applications using cypress-react-unit-test

bahmutov profile image Gleb Bahmutov Updated on ・10 min read

Note: this blog post takes the twelve testing examples from 12 Recipes for testing React applications using Testing Library blog post by João Forja, where the same examples are tested using testing-library. This blog post uses cypress-react-unit-test + Cypress combination to test exactly the same scenarios.

Note 2: you can find these tests in the repo bahmutov/12-testing-recipes

Table of Contents

  1. Invokes given callback
  2. Changes current route
  3. Higher Order Component
  4. Component cleans up on unmount
  5. Depends on Context Provider
  6. Uses functions that depend on time
  7. Custom hooks
  8. Portal
  9. Focus is on correct element
  10. Order of elements
  11. Selected option
  12. Dynamic page titles

Invokes given callback

  • We're testing that after some interaction the component calls a given callback.
  • We give a mock function to the component under test and interact with it so that it calls the callback. Then we assert we called the function with the expected parameters. If relevant, we also check the number of times the function was called.
/// <reference types="cypress" />
import React from 'react'
import { mount } from 'cypress-react-unit-test'

describe('Invoke callback', () => {
  function Button({ action }) {
    return <button onClick={() => action()}>Call</button>
  }

  it('callback is called on button click', () => {
    const callback = cy.stub()
    mount(<Button action={callback} />)

    cy.contains('button', /call/i)
      .click()
      .then(() => {
        expect(callback).to.have.been.calledOnce
        expect(callback).to.have.been.calledWithExactly()
      })
  })
})

The test runs and we can see the stub count of 1 in the Cypress' Command Log on the left.

Click invokes the stub we have passed to the component

The same test can be written differently avoiding .then by saving the reference to the stub using an alias.

it('callback is called on button click using an alias', () => {
  mount(<Button action={cy.stub().as('callback')} />)

  cy.contains('button', /call/i).click()
  cy.get('@callback')
    .should('have.been.calledOnce')
    .and('have.been.calledWithExactly')
})

Prop handler saved as an alias

Changes current route

  • We're testing that the component redirects the user to an expected router with the expected query parameters after an interaction.
  • We first create a routing environment similar to that in which we'll use the component. We set up that environment so we can capture the URL to which the component will redirect us. We interact with the component to cause the redirect. We then assert that we were redirected to the URL we expected.
/// <reference types="cypress" />
import React, { useState } from 'react'
import { MemoryRouter, Route, useHistory } from 'react-router-dom'
import { mount } from 'cypress-react-unit-test'

describe('Changes current route', () => {
  it('On search redirects to new route', () => {
    let location
    mount(
      <MemoryRouter initialEntries={['/']}>
        <Route path="/">
          <SearchBar />
        </Route>
        <Route
          path="/*"
          render={({ location: loc }) => {
            location = loc
            return null
          }}
        />
      </MemoryRouter>,
    )

    cy.get('input#query').type('react')
    cy.get('input[type=submit]')
      .click()
      .then(() => {
        expect(location.pathname).to.equal('/search-results')
        const searchParams = new URLSearchParams(location.search)
        expect(searchParams.has('query')).to.be.true
        expect(searchParams.get('query')).to.equal('react')
      })
  })
})

function SearchBar() {
  const history = useHistory()
  const [query, setQuery] = useState('')

  return (
    <form
      onSubmit={function redirectToResultsPage(e) {
        debugger
        e.preventDefault()
        history.push(`/search-results?query=${query}`)
      }}
    >
      <label htmlFor="query">search</label>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.currentTarget.value)}
        id="query"
      />
      <input type="submit" value="go" />
    </form>
  )
}

You can see where the test is typing and the button it is clicking

Routes test

Higher Order Component

  • We're testing that a HOC gives the props we expect to the wrapped component.
  • We first create a mock component for the HOC to wrap. The mock component will store the received props in a variable. After rendering the component returned by the HOC we assert that it gave the mock component the props we expected.
import React from 'react'
import { mount } from 'cypress-react-unit-test'

function withSum(WrappedComponent, numbersToSum) {
  const sum = numbersToSum.reduce((a, b) => a + b, 0)
  return () => <WrappedComponent sum={sum} />
}

describe('Higher Order Component', () => {
  it('Adds number and gives result as a prop', () => {
    let result
    function WrappedComponent({ sum }) {
      result = sum
      return null
    }
    const ComponentWithSum = withSum(WrappedComponent, [4, 6])
    mount(<ComponentWithSum />)

    // mount is an asynchronous command
    cy.then(() => {
      expect(result).to.equal(10)
    })
  })
})

Higher order component test

Seeing "Unknown" due to anonymous function in the mounting message weird, we can give it a label:

mount(<ComponentWithSum />, { alias: 'ComponentWithSum' })

Mounted component with an alias

Component cleans up on unmount

  • We want to assert that a component subscribes after mount and unsubscribes after unmount.
  • We start by mocking the subscription methods so we can assert they get called. We then render the component and assert that it subscribed. All that's left to do is make the component unmount and assert it unsubscribed.
/// <reference types="cypress" />
import React, { useEffect } from 'react'
import { mount, unmount } from 'cypress-react-unit-test'

function ComponentThatSubscribes({ subscriptionService }) {
  useEffect(() => {
    subscriptionService.subscribe()
    return () => subscriptionService.unsubscribe()
  }, [subscriptionService])
  return null
}

describe('Component cleans up on unmount', () => {
  it('Subscribes and unsubscribes when appropriate', () => {
    const subscriptionService = {
      subscribe: cy.stub().as('subscribe'),
      unsubscribe: cy.stub().as('unsubscribe'),
    }

    mount(<ComponentThatSubscribes subscriptionService={subscriptionService} />)

    cy.get('@subscribe')
      .should('have.been.calledOnce')
      .and('have.been.calledWithExactly')

    unmount()

    cy.get('@unsubscribe')
      .should('have.been.calledOnce')
      .and('have.been.calledWithExactly')
  })
})

Again, both cy.stub instances can be found in the Command Log.

Mount and unmount callbacks

Depends on Context Provider

  • We want to test a component that depends on a context Provider
  • To test the component, we'll recreate the environment in which we'll use the component. In other words, we'll wrap the component in the Context Provider.
/// <reference types="cypress" />
import React, { useContext } from 'react'
import { mount } from 'cypress-react-unit-test'

const UserContext = React.createContext()

function UserFullName() {
  const { user } = useContext(UserContext)
  return <p>{user.fullName}</p>
}

describe('Proivder', () => {
  it('displays name of current user', () => {
    mount(
      <UserContext.Provider value={{ user: { fullName: 'Giorno Giovanna' } }}>
        <UserFullName />
      </UserContext.Provider>,
    )
    cy.contains('Giorno Giovanna').should('be.visible')
  })
})

The test passes, and if we hover over the "contains" command, the found element is highlighted on the page.

Highlighted element found using cy.contains command

Uses functions that depend on time

  • We want to test a component that depends on real-time. In this example, that dependency comes from using setTimeout().
  • When testing components that depend on real-time, we need to be aware that those tests shouldn't take too long. One way to do that is to have the component receive the time interval as a prop to allow us to configure a shorter time interval for tests than we would have in production. One way to do so that is to mock the clock during the test to speed things up.
/// <reference types="cypress" />
import React, { useState, useEffect } from 'react'
import { mount } from 'cypress-react-unit-test'

function TrafficLight() {
  const timeUntilChange = 500
  const [light, setLight] = useState('Red')
  useEffect(() => {
    setTimeout(() => setLight('Green'), timeUntilChange)
  }, [timeUntilChange])
  return <p>{light}</p>
}

describe('Functions that depend on time', () => {
  it('Changes from red to green to after timeout', () => {
    cy.clock()
    mount(<TrafficLight />)

    cy.contains(/red/i).should('be.visible')
    cy.tick(500)
    cy.contains(/green/i).should('be.visible')
  })
})

We are using cy.clock to freeze the application's clock. After checking if the light is red at the beginning, we fast-forward the timer by 500ms and check the light again. This time it shows "Green". We can confirm the DOM structure during each step by using the built-in Cypress time-traveling debugger.

Inspecting the component

Custom hooks

  • We want to test a custom hook.
  • Since we're testing a hook, we'll need to call it inside a component otherwise we'll get an error. Therefore, we'll create a mock component, use the hook inside it, and store what the hook returns in a variable. Now we can assert what we need to assert using that variable.
/// <reference types="cypress" />
import React, { useState, useCallback } from 'react'
import { mount } from 'cypress-react-unit-test'

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = useCallback(() => setCount((x) => x + 1), [])
  return { count, increment }
}

describe('Hooks', () => {
  it('counter increments', () => {
    let counter
    function MockComponent() {
      counter = useCounter()
      return null
    }

    mount(<MockComponent />)
      .then(() => {
        expect(counter.count).to.equal(0)
        counter.increment()
      })
      .then(() => {
        expect(counter.count).to.equal(1)
      })
  })
})

Testing a component with hooks

You can also test the hook directly - cypress-react-unit-test will create a mock component for you.

import { useState, useCallback } from 'react'
import { mountHook } from 'cypress-react-unit-test'

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = useCallback(() => setCount(x => x + 1), [])
  return { count, increment }
}

describe('useCounter hook', function() {
  it('increments the count', function() {
    mountHook(() => useCounter()).then(result => {
      expect(result.current.count).to.equal(0)
      result.current.increment()
      expect(result.current.count).to.equal(1)
      result.current.increment()
      expect(result.current.count).to.equal(2)
    })
  })
})

Testing React hook use mountHook function

Portal

  • We want to test a component that's a portal.
  • A portal needs a DOM node to be rendered into. So to test it, we'll have to create that DOM node. After we make the assertions, we'll have to remove the DOM node as not to affect other tests.
/// <reference types="cypress" />
import React, { useRef, useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
import { mount } from 'cypress-react-unit-test'

function PortalCounter() {
  const el = useRef(document.createElement('div'))
  const [count, setCount] = useState(0)

  useEffect(() => {
    const modalRoot = document.getElementById('modal-root')
    const currentEl = el.current
    modalRoot.appendChild(currentEl)
    return () => modalRoot.removeChild(currentEl)
  }, [])

  return ReactDOM.createPortal(
    <>
      <section aria-live="polite">
        count: <span data-testid="counter">{count}</span>
      </section>
      <button type="button" onClick={() => setCount((c) => c + 1)}>
        inc
      </button>
    </>,
    el.current,
  )
}

describe('Portal', () => {
  it('PortalCounter starts at 0 and increments', () => {
    const modalRoot = document.createElement('div')
    modalRoot.setAttribute('id', 'modal-root')
    document.body.appendChild(modalRoot)

    mount(<PortalCounter />)
    cy.contains('[data-testid=counter]', 0)
    cy.contains('button[type=button]', 'inc').click()
    cy.contains('[data-testid=counter]', 1)
  })
})

Testing the portal component

Focus is on correct element

  • We want to test that the focus on the element we expect.
  • We can verify if an element has focus or not by using .should('have.focus') assertion.
/// <reference types="cypress" />
import React from 'react'
import { mount } from 'cypress-react-unit-test'

function NameForm() {
  return (
    <form>
      <label htmlFor="name">Name</label>
      <input id="name" type="text" />
    </form>
  )
}

describe('Focus is on correct element', () => {
  it('clicking on label gives focus to name input', () => {
    mount(<NameForm />)

    cy.contains('label', 'Name').click()
    cy.get('input#name').should('have.focus')
  })
})

Confirming the clicked input has the focus

Order of elements

  • We want to test that a list of elements is rendered in the expected order.
  • We'll take advantage of cy.get queries returning elements in the order in which they appear on the HTML.
  • It's important to note that this approach doesn't take into account CSS that might change the order in which the elements are displayed.
/// <reference types="cypress" />
import React from 'react'
import { mount } from 'cypress-react-unit-test'

function NamesList({ names }) {
  return (
    <ul>
      {names.map((name) => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  )
}

describe('Order of elements', () => {
  it('renders names in given order', () => {
    const names = ['Bucciarati', 'Abbacchio', 'Narancia']

    mount(<NamesList names={names} />)
    cy.get('li').should(($li) => {
      expect($li[0]).to.have.text(names[0])
      expect($li[1]).to.have.text(names[1])
      expect($li[2]).to.have.text(names[2])
    })
  })
})

Comparing text in each element

In Cypress, you can see the component renders the elements in the expected order and then use visual testing to automatically validate it.

You can further simplify the above test using .each command

cy.get('li').each((li, k) => {
  expect(li).to.have.text(names[k])
})

Selected Option

  • We want to test that an input is checked.
  • We can use should('be.checked') to test if an element is checked.
/// <reference types="cypress" />
import React from 'react'
import { mount } from 'cypress-react-unit-test'

function SeasonsForm() {
  return (
    <form>
      <p>Best season:</p>
      <section>
        <input name="season" type="radio" id="winter" value="winter" />
        <label htmlFor="winter">Winter</label>
        <input name="season" type="radio" id="spring" value="spring" />
        <label htmlFor="spring">Spring</label>
        <input
          name="season"
          checked
          readOnly
          type="radio"
          id="summer"
          value="summer"
        />
        <label htmlFor="summer">Summer</label>
        <input name="season" type="radio" id="autumn" value="autumn" />
        <label htmlFor="autumn">Autumn</label>
      </section>
    </form>
  )
}

describe('Selected option', () => {
  it('Has Summer pre-selected', () => {
    mount(<SeasonsForm />)
    cy.get('input[type=radio]#summer').should('be.checked')
  })
})

Selected option test

Dynamic page titles

  • We want to test that the title of the current page is updated.
  • We access the current title by using cy.title. Even if the document title won't be immediately updated, Cypress will automatically waiting and retrying the assertion should('equal', '1').
/// <reference types="cypress" />
import React, { useState } from 'react'
import { Helmet } from 'react-helmet'
import { mount } from 'cypress-react-unit-test'

function DocTitleCounter() {
  const [counter, setCounter] = useState(0)

  return (
    <>
      <Helmet>
        <title>{String(counter)}</title>
      </Helmet>
      <button onClick={() => setCounter((c) => c + 1)}>inc</button>
    </>
  )
}

describe('Document title', () => {
  it('Increments document title', () => {
    mount(<DocTitleCounter />)
    cy.title().should('equal', '0')
    cy.contains('button', /inc/i).click()
    cy.title().should('equal', '1')
  })
})

Document title test

Other resources

Discussion (1)

Collapse
jooforja profile image
João Forja 💭

I really enjoyed seeing how the examples mapped from Jest+RTL to Cypress. I haven't yet written unit tests using Cypress, but I'll give it a try since I like the idea of having all of my tests run a real browser.

Also, thanks for referencing the original blog post!

Forem Open with the Forem app