Fecha de publicación

Testing guides and good patterns 🧪

Autores

Alt Text

How to unit test components 🧪

Intro

  • Testing UI is a large topic spanning across different families of tests and methodologies.
    The "what to test" topic alone would need a good few pages to be properly covered.

    This short document only focuses on how we structure our unit/integration tests in Jest.

    As for the rest, until we write some documentation on the topic, we are aligned on the philosophy of the react-testing-library, and therefore of his author, Kent C. Dodds.
    Numerous blog articles, youtube videos and workshops from him are available on the internet, I encourage you to have a look.


The base

  • The skeleton for a component test file is the following:

    import React from 'react'
    import { render } from '@testing-library/react'
    import MyComponent from './MyComponent'
    
    const renderComponent = (props = {}) => render(<MyComponent {...props} />)
    
    describe('`MyComponent`', () => {
      it('renders without crashing', () => {
        renderComponent()
      })
    })
    
  • The crash-test:
    A basic test that ensures the component doesn't crash when we attempt to render it on the page.
    ⚠️: Add this test only if there is nothing else to test.
    It happens that the component is so trivial (nothing functional, no variations) that there is nothing else to test.

  • The factory function renderComponent:
    Convention to have instantiation centralized in one place. (Test Object Factory pattern)
    By removing some of the instantiation boilerplate, it makes tests smaller and easier to read. Tests can show what they really care about, without the noise of initializing required props.
    It also makes refactoring easier.

    const renderComponent = ({ id = 288, name = 'Austin Powers', ...otherProps } = {}) =>
      render(<MyComponent id={id} name={candidateName} {...otherProps} />)
    
  • 🌵 Don't rely on the default values in your tests.
    A test should explicitly show what props matter for them: it makes it clearer and more robust.

    // GOOD ✅
    it('displays the user name', () => {
      const { getByText } = renderComponent({ name: 'Austin Powers' })
    
      expect(getByText('Austin Powers')).toBeInTheDocument()
    })
    
    // BAD - Don't rely on default values ❌
    it('displays the user name', () => {
      const { getByText } = renderComponent()
    
      expect(getByText('Austin Powers')).toBeInTheDocument()
    })
    

Structure of the test file

  • Imports
  • Definitions
  • Tests
// Import section
import React from 'react';
import { render } from 'utils/tests';
import MyComponent from './MyComponent';

// Definitions section
const SOME_MOCK_DATA = [1, 2, 3];

const renderComponent = (....) = () => {
  .....
}

// Tests section
describe('`MyComponent`', () => {
  ......
});

About top-level variables

  • It is pretty common to define mock data in this section that are used across different tests and/or used in the file's factory function.
    const in Javascript ensures that the variable reference won't change - but that's it, it doesn't ensure value immutability.
    Obviously, we can't have a test mutating a shared variable - that could result in a failure in another test, and the issue would be hard to find.

    To circumvent this problem, we are using the following convention:

    Top-level constant variables should be written in screaming snake case ( SCREAMING_SNAKE_CASE)

    This has the added benefit to clearly show in a test that we are using a top-level constant variable.

    // Definition block
    const CANDIDATE_AUSTIN = {
      id: 1,
      name: 'Austin Powers',
    }
    
    // Test block
    // ...
    it('displays the candidate name', () => {
      const { name } = CANDIDATE_AUSTIN
      renderComponent({ name })
    
      expect(screen.getByText(name)).toBeInTheDocument()
    })
    

Common tests

Some tests are plain boilerplate. Let's cover some of them.

Testing a click handler

  • A component has a button onClick prop for the click handler.

    import { render, screen } from '@testing-library/react'
    import userEvent from '@testing-library/user-event'
    
    // ...
    
    it('executes the `onClick` handler when the button is clicked', () => {
      const onClick = jest.fn()
      renderComponent({ onClick })
    
      const button = screen.getByText('buttonText')
      userEvent.click(button)
    
      expect(onClick).toHaveBeenCalledWith({ your: 'props' })
    })
    

    💡 A common mistake here when we need to wait for some async operation that allows us to get the element on which we want to apply an event (click, hover, etc) is to wrap the wait for the element + the event in a single line, i.e.:

    // Avoid ❌
    await waitFor(() => userEvent.click(screen.getByTestId('thing-to-click')))
    

    That's not the ideal since is not what the user really does, first, the user waits for an element THEN click it, so is better to have these separated into two statements like this:

    // Good ✅
    await waitFor(() => {
      expect(screen.getByTestId('thing-to-click')).toBeInTheDocument()
    })
    
    userEvent.click(screen.getByTestId('thing-to-click'))
    

    🧙🏼‍♂️ Another good pattern is to wait and expect that the element is in the document

Mocking an external dependency (1/2)

  • useParams hook from react-router-dom returns custom url params

    import { useParams } from 'react-router-dom'
    
    jest.mock('react-router-dom', () => ({
      ...jest.requireActual('react-router-dom'),
      useParams: () => ({ userId: '123' }),
    }))
    
    it('receives the userId', () => {
      renderComponent()
    
      expect(await screen.findByText(userId))
    })
    

Mocking an external dependency and spying on it (2/2)

  • A button executes useHistory.push hook from react-router-dom

    import { useHistory } from 'react-router-dom'
    
    jest.mock('react-router-dom', () => ({
      ...jest.requireActual('react-router-dom'),
      useHistory: jest.fn(),
    }))
    
    describe('When button is enabled', () => {
      it('pushes the screen when button is clicked', () => {
        const historyPushMock = jest.fn()
        useHistory.mockImplementation(() => ({ push: historyPushMock }))
        renderComponent()
    
        const pushToUserButton = screen.getByText('buttonText')
        userEvent.click(pushToUserButton)
    
        await waitFor(() => {
          expect(historyPushMock).toHaveBeenCalled()
        })
      })
    })
    

Mocking an external dependency and update values dynamically

  • A component get state from useLocation.state hook from react-router-dom

    import { useLocation } from 'react-router-dom'
    
    jest.mock('react-router-dom', () => ({
      ...jest.requireActual('react-router-dom'),
      useLocation: jest.fn(),
    }))
    
    describe('When location state is collapsed', () => {
      const useMockedLocation = useLocation as jest.Mock
    
      beforeEach(() => {
        useMockedLocation.mockImplementation(() => ({
          state: { collapsed: true },
        }))
      })
    
      it('renders Collapsed component', () => {
        renderComponent()
    
        expect(screen.getByTestId('collapsed-component')).toBeInTheDocument()
      })
    })
    
  • A link:

    it('renders the link', () => {
      const url = 'https://pimpam.com/user/1'
      const { container } = renderComponent({
        // ....
      })
      const link = container.querySelector('a')
    
      expect(link).not.toBeNull()
      expect(link.href).toBe(url)
    })
    

    Notes: Testing that the user is redirected to the url is not necessary - we are not testing the HTML language and its elements. We can safely assume that a <A> tag works. We could though, test that this element is visible and not disabled.


Todos and not

  • External Snapshot testing ❌
    Snapshot testing tests the integrity of the DOM structure of the component and its sub-components.
    It creates more problems than it solves (maintenance cost, false sense of confidence, tightened up to implementation details, dev getting lazy, and using it all the time incorrectly).

    🌵 Snapshot testing is often confused with screenshot testing - a type of test ensuring visual regression. It is important to note that it doesn't test that the component displays as expected.

    Refactoring of the code shouldn't break tests. They should react to problems, not to changes.

    💡 Small, really specific inline snapshot testing is ok ! Especially when testing external URLs that may be updated. Snapshot tooling will enable you to update those really easily.

  • Wait for renderComponent ❌
    Do not await waitFor(() => renderComponent()), if we need to wait for something to be rendered, we should to waitFor the expect directly.

  • Select or test by class ❌
    Css classes are brittle selectors, to be avoided.
    The link between test and style is easy to miss, and you shouldn't have to watch for broken tests when changing styles - except in rare cases.
    Use data-testid, semantic html tag, or text, to select elements.

  • Avoid vague assertions ❌
    Do not use toBeTruthy,toBeFalsy- same logic as why we avoid the double equal in JS : using coercion can lead to hard-to-find errors and false-positive in tests.
    If a function is expected to return a boolean simply use toBe(true) and toBeFalse.
    toBeDefined is even more dangerous, as null is defined.
    It is not unusual to see a test such as : expect(queryElementById('reply-icon')).toBeDefined() ... which will always pass.

  • Do not mock component files ❌
    We are aligned with the react-testing-library philosophy and avoid mocking child components (shallow rendering) in favor of testing on the real DOM output (integration tests).
    There are of course exceptions (ie: graphs) where this is not possible.

  • Use screen for querying and debugging. Because querying the entire document.body is very common, DOM Testing Library also exports a screen object which has every query that is pre-bound to document.body

  • Use userEvent to simulate the real events that would happen in the browser as the user interacts with it.

  • If there is only one element to expect, instead of wrapping it inside waitFor we can use await findBy* (less code to write and easier to maintain)

  • If the component that we want to render needs to be wrapped, pass a react component as the wrapper option to have it rendered around the inner element. This is most useful for creating reusable custom render functions for common data providers. render(MyComponent, { wrapper: AppProvider })