- Fecha de publicación
Testing guides and good patterns 🧪
- Autores
- Nombre
- x0s3
- @x0s3js
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 fromreact-router-dom
returns custom url paramsimport { 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 fromreact-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 fromreact-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() }) })
Test with links
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 notawait 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.
Usedata-testid
, semantic html tag, or text, to select elements.Avoid vague assertions ❌
Do not usetoBeTruthy
,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 usetoBe(true)
andtoBeFalse
.toBeDefined
is even more dangerous, asnull
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.
Recommended
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 todocument.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 useawait 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 })