Написание тестов для проверки компонентов React — нелегкое занятие. Я потратил много времени на поиски правильных вариантов решения этой задачи и с трудом их нашел. Поэтому мне захотелось написать эту статью, чтобы поделиться с вами опытом и знаниями.
Если вы хотите увидеть полноценный пример работы, взгляните на демонстрационный шаблон.
Имитация
После настройки среды для работы Jest и библиотеки тестирования React вы можете обнаружить, что некоторые компоненты выходят из строя при рендеринге во время теста.
Это происходит потому, что компонент, отображаемый в тесте, может работать только в определенных средах. Обычно он настраивается в средах разработки и эксплуатации, но мы сымитируем “фейковую” среду при выполнении теста.
Я помещаю файлы, созданные в этом разделе, в каталог jest/mock/ и добавляю псевдоним @jest в файл webpack.config.js:
alias: {
'@js': path.resolve(__dirname, 'src/js/'),
'@scss': path.resolve(__dirname, 'src/scss/'),
'@img': path.resolve(__dirname, 'img/'),
'@jest': path.resolve(__dirname, 'jest/')
}
Его также нужно добавить в jest.config.js:
moduleNameMapper: {
'^@js(.*)$': '<rootDir>/src/js$1',
'^@scss(.*)$': '<rootDir>/src/scss$1',
'^@img(.*)$': '<rootDir>/img$1',
'^@jest(.*)$': '<rootDir>/jest$1'
},
Имитация Redux
1. Создайте копию реального хранилища (store).
В некоторых случаях нужна только среда, которая может отрисовать компонент с состоянием по умолчанию. Это можно сделать, скопировав реальный файл истории и удалив ненужные конфигурации (например, для Redux DevTools).
// jest/mock/store/index.js
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import reducer from '@js/reducers'
const composeEnhancers = compose
const middleware = [thunk]
export default createStore(
reducer,
composeEnhancers(applyMiddleware(...middleware))
)
2. Сымитируйте компонент Provider.
При использовании React с Redux нужно обернуть компоненты в компонент Provider из библиотеки react-redux. То же самое нужно сделать при рендеринге компонента в тесте. Чтобы избежать повторного написания одной и той же логики, создаем компонент для ее обработки:
// jest/mock/MockProvider.jsx
import { Provider } from 'react-redux'
// используйте копию 1 в качестве хранилища по умолчанию
import store from './store'
const MockProvider = ({ children = null, mockStore = null }) => {
return (
<Provider store={mockStore || store}>
{ children }
</Provider>
)
}
MockProvider.propTypes = {
mockStore: PropTypes.object, // этот пропс позволяет нам установить store в определенное состояние
children: PropTypes.node
}
export default MockProvider
Нам также нужно создать модуль для различных состояний store
. Этот модуль будет использовать библиотеку redux-mock-store для создания имитированного store
. Он также заменит атрибут dispatch
имитированного store
на имитированную функцию Jest
, чтобы при необходимости можно было отслеживать состояние. Начальное состояние store
можно установить с помощью специального параметра.
// jest/mock/store/createMockStore.js
import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
const middleware = [thunk]
export default (initialState) => {
const mockStore = configureMockStore(middleware)(initialState)
// eslint-disable-next-line no-undef
// замените метод dispatch "шпионом" и поддерживайте функциональность
mockStore.dispatch = jest.fn(mockStore.dispatch)
return mockStore
}
Имитация маршрутов и истории
Вот и еще один похожий случай. Чтобы заставить работать компоненты, использующие библиотеку react-router-dom, нужно обернуть их в компонент Router. Для этого также можно создать модуль:
// jest/mock/mockRouter.jsx
import { Router } from 'react-router-dom'
import { createBrowserHistory } from 'history'
const MockRouter = ({ children }) => {
return (
<Router history={createBrowserHistory()}>
{ children }
</Router>
)
}
MockRouter.propTypes = {
children: PropTypes.node // пропс для отправки компонентов, которые вы хотите отобразить
}
export default MockRouter
Имитация модулей импорта
В некоторых случаях необходимо имитировать модули (включая пакеты из npm) в тесте. Вот пример того, как это сделать:
import { render, cleanup } from '@testing-library/react'
import MainPage from '../MainPage'
// *** это должно быть записано в глобальной области видимости ***
// сымитируйте библиотеку react-router-dom
// и замените атрибут useHistory модуля
jest.mock('react-router-dom', () => ({
useHistory: () => ({
push: jest.fn()
})
}))
afterEach(cleanup)
beforeEach(() => {
// ...
})describe('MainPage.jsx', () => {
// ...
}
Подробнее об имитированных функциях можно прочитать в официальной документации, а больше примеров вы увидите позже.
Что тестировать?
Автор react-testing-library — Kent C. Dodds — создал этот инструмент, чтобы помочь разработчикам избежать тестирования деталей реализации. Поэтому нам не нужно исправлять тестовые примеры при рефакторинге компонентов.
Это означает, что вам нужно сосредоточиться на тестировании того, как пользователи взаимодействуют с компонентами. Пишите тесты так, будто пользователь взаимодействует с компонентом, и проверяйте, правильно ли компонент реагирует. Важно знать, как выполнять следующие действия.
1. Имитация пользователя
Для имитации пользователя можно воспользоваться библиотекой @testing-library/user-event. Возьмем fireEvent
из библиотеки @testing-library/react для обработки событий, с которыми не может справиться user-event.
2. Выполнение DOM-запросов
- Запросы из объекта
screen
После рендеринга компонента с помощью метода render
вы можете получить доступ к результату с помощью объекта screen
и выполнить запрос с его помощью. Подробнее об этом можно прочитать в официальной шпаргалке (там есть удобная таблица).
- Запрос по
className
Объект screen
не предоставляет метод для запроса по className
, но все же есть способ сделать это. Вы можете получить корневой DOM с помощью атрибута container
, а затем вызвать метод getElementsByClassName
:
import { render } from '@testing-library/react'
it('some description', () => {
const screen = render(<Component />)
screen.container.getElementsByClassName('<the_target_class_name>')
}
Сценарии
1. Простой компонент
- Рендеринг в соответствии с пропсами
Если вы тестируете компоненты, которые различаются только пропсами, то нужно написать тесты для отправки различных пропсов компонентам и проверить их.
Чтобы упростить задачу, я возьму общий случай и напишу тесты только для:
- всех пропсов с истинным значением;
- всех пропсов с ложным значением.
Используйте библиотеку react-test-renderer для выполнения snapshot-теста для этих сценариев: так вы сэкономите время, которые потратили бы на выполнение запросов.
import { cleanup } from '@testing-library/react'
import renderer from 'react-test-renderer' // renderer for snapshot test
import UserInfo from '../UserInfo'
afterEach(cleanup)
describe('UserInfo.jsx', () => {
it('snapshot renders correctly, truthy values', () => {
const tree = renderer
.create(<UserInfo
userId="202200001"
userName="Test User"
userImg="./test_user.img"
/>)
.toJSON()
expect(tree).toMatchSnapshot()
})
it('snapshot renders correctly, falsy values', () => {
const tree = renderer
.create(<UserInfo/>)
.toJSON()
expect(tree).toMatchSnapshot()
})
})
- Рендеринг в соответствии с редьюсерами
Тестирование компонентов, которые изменяются в зависимости от состояния редьюсеров, очень похоже на предыдущий случай. Вы можете сымитировать состояние редьюсеров при помощи установки в определенное значение и отрисовать компоненты. Затем, опять же, вы можете проверить результат, выполнив несколько запросов или snapshot-тестов:
import { cleanup } from '@testing-library/react'
import renderer from 'react-test-renderer'
import MockProvider from '@jest/mock/MockProvider'
import createMockStore from '@jest/mock/store/createMockStore'
import UserInfoV2 from '../UserInfoV2'
afterEach(cleanup)
describe('UserInfoV2.jsx', () => {
it('snapshot renders correctly, truthy values', () => {
const store = createMockStore({
userInfo: {
userId: '202200001',
userName: 'Test User',
userImg: './test_user.img'
}
})
const tree = renderer
.create(<MockProvider mockStore={store}>
<UserInfoV2 />
</MockProvider>)
.toJSON()
expect(tree).toMatchSnapshot()
})
it('snapshot renders correctly, falsy values', () => {
const store = createMockStore({
userInfo: {
userId: '',
userName: '',
userImg: ''
}
})
const tree = renderer
.create(<MockProvider mockStore={store}>
<UserInfoV2 />
</MockProvider>)
.toJSON()
expect(tree).toMatchSnapshot()
})
})
2. Компонент с интервалами и промисами
Если вы ждете, пока закончится какой-либо процесс, вам стоит попробовать запросы waitFor
и findBy
. Вы можете легко найти примеры таких запросов в интернете, поэтому я опущу их подробное описание.
3. Компонент с хуками
Как уже упоминалось в разделе “Имитация”, мы можем использовать jest
для имитации всех импортированных модулей. То же самое касается и хуков!
- Параметры роутинга SPA
Предположим, у нас есть компонент DataPage
, использующий хук useParams
из библиотеки react-router-dom:
import { useParams } from 'react-router-dom'
const DataPage = (props) => {
const { dataId = '' } = useParams()
const data = {
data1: 'this is the content form data 1',
data2: 'this is the content form data 2',
}
return (
<div className="data-page">
{
data[dataId]
? <div className="data-page__content">{data[dataId]}
</div>
: <div className="data-page__not-found">content not found</div>
}
</div>
)
}
export default DataPage
Ниже указан способ имитации значения, которое вернет useParams
:
import { screen, render, cleanup } from '@testing-library/react'
import DataPage from '../DataPage'
afterEach(cleanup)
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
dataId: 'data1'
})
}))
describe('DataPage.jsx', () => {
it('render the content if data exist', () => {
render(<DataPage />)
screen.getByText('this is the content form data 1')
})
})
- Пользовательский хук возвращает функцию в массиве
Предположим, у нас есть компонент, подобный этому:
import { useSearchData } from '@js/hooks/searchData'
const SearchPage = (props) => {
const [search] = useSearchData() // пользовательский хук
const onSearchButtonClick = () => {
search()
}
return (
<div className="search-page">
<button onClick={onSearchButtonClick}>search</button>
</div>
)
}
export default SearchPage
Вы можете сымитировать пользовательский хук следующим образом:
import { screen, render, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SearchPage from '../SearchPage'
import { useSearchData } from '@js/hooks/searchData'
afterEach(cleanup)
jest.mock('@js/hooks/searchData', () => {
const spy = jest.fn()
return {
useSearchData: () => {
return [spy]
}
}
})
describe('SearchPage.jsx', () => {
it('render the content if data exist', () => {
render(<SearchPage />)
// инициализируйте импортированную функцию
// и получите ссылку на "шпиона"
const [search] = useSearchData()
userEvent.click(screen.getByText('search'))
expect(search).toHaveBeenCalled()
})
})
4. Компонент с импортированными модулями
Подробнее рассмотрим тестирование компонента DataPage
. В предыдущем примере мы можем сымитировать реализацию useParams
только один раз.
Если вы хотите сохранить маневренность, нужно сначала сымитировать весь модуль и использовать метод mockImplementationOnce
или mockReturnValueOnce
перед рендерингом компонента. Таким образом, вы можете сымитировать реализацию или возвращаемое значение функции для каждого тестового случая.
Кроме того, если код в компоненте зависит от результата работы функции, имитирование реализации функции позволяет избежать ошибок.
import { screen, render, cleanup } from '@testing-library/react'
import { useParams } from 'react-router-dom'
import DataPage from '../DataPage'
afterEach(cleanup)
jest.mock('react-router-dom', () => {
const spy = jest.fn()
return {
...jest.requireActual('react-router-dom'),
useParams: spy
}
})
describe('DataPage.jsx', () => {
it('render the content if data exist', () => {
useParams.mockImplementationOnce(() => ({
dataId: 'data1'
}))
render(<DataPage />)
screen.getByText('this is the content form data 1')
})
it('render the hint if data does not exist', () => {
useParams.mockReturnValueOnce({
dataId: ''
})
render(<DataPage />)
screen.getByText('content not found')
})
})
Как правило, вы можете сымитировать всю библиотеку и продолжить следующим образом:
jest.mock('react-router-dom')
Но если мы имитируем всю библиотеку react-router-dom, атрибут useParams
по какой-то причине становится неопределенным, поэтому я использую способ, указанный в последнем примере.
5. Компоненты, которые диспетчеризируют действия
Хотя мануалы настаивают на том, что тестирования деталей реализации стоит избегать, я все же считаю, что необходимо тестировать действия, которые осуществляют диспетчеризацию компонентов. В частности, меня интересуют следующие моменты.
- Время. Используется ли файл диспетчеризации в нужное время посредством нужного действия?
- Параметры. Правильно ли переданы параметры в действие?
Вы самостоятельно можете проверить время и параметры этих импортированных функций (включая импортированные модули, действия и библиотеки).
Возможно, пример ниже покажется странным, но я пытаюсь охватить более широкий сценарий в компоненте:
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
setSettings, // a plain object action
startGetSettings // a redux-thunk action
} from '@js/actions'
const DemoPage = (props) => {
const dispatch = useDispatch()
const { title = '' } = useSelector(state => state.settings)
useEffect(() => {
dispatch(setSettings({
title: 'Title set when the component mount.'
}))
}, [])
const onFetchClick = () => {
dispatch(startGetSettings({ foo: 'bar' }))
}
return (
<div className="demo-page">
<h1>{title}</h1>
<button onClick={onFetchClick}>fetch settings</button>
</div>
)
}
export default DemoPage
При тестировании действия, которое возвращает обычный объект, все просто:
it('should dispatch setSettings action when the component mount', () => {
const mockStore = createMockStore({
settings: {}
})
render(<MockProvider mockStore={mockStore}>
<DemoPage />
</MockProvider>)
// N-й элемент из результата метода getActions
// будет объект, отправленный N-м диспетчером.
expect(mockStore.getActions()[0].type).toBe(actions.SET_SETTINGS)
expect(mockStore.getActions()[0].data.title).toBe('Title set when the component mount.')
expect(mockStore.dispatch).toHaveBeenCalledTimes(1)
})
С другой стороны, задача усложняется при тестировании действия redux-thunk. Действия должны возвращать объект, и если сымитировать возвращаемое значение как промис, это приведет к ошибке.
Поэтому я решил имитировать эти действия в общие действия:
jest.mock('@js/actions', () => ({
// сохраняем функциональность модуля действий
...jest.requireActual('@js/actions'),
// замените thunk-action на обычное объектное действие
// для проверки полученных параметров.
startGetSettings: parameters => ({ type: 'MOCK', parameters })
}))
Проверьте время и параметры с помощью результата метода getActions
:
it('should dispatch startGetSettings action when user click the button', async () => {
const mockStore = createMockStore({
settings: {}
})
render(<MockProvider mockStore={mockStore}>
<DemoPage />
</MockProvider>)
userEvent.click(screen.getByText('fetch settings'))
await waitFor(() => {
expect(mockStore.getActions()[1].parameters)
.toEqual({ foo: 'bar' })
expect(mockStore.dispatch).toHaveBeenCalledTimes(2)
})
})
Самое важное тут — правильно определить границы. Если вы пишете тесты для компонента, то нужно сосредоточиться на логике в этом компоненте. Достаточно протестировать время и параметры для импортированных функций. Для тестирования деталей в этих компонентах лучше создавать зависимые тестовые файлы.
Читайте также:
- SonarQube: за качество кода отвечаем
- Как использовать библиотеку веб-компонентов React от Uber
- Версионирование независимых UI-компонентов
Читайте нас в Telegram, VK и Дзен
Перевод статьи tabsteveyang: How to Test React Components