Привет, друзья!
В данном туториале я покажу вам, как тестировать компоненты на React с помощью Jest и Testing Library.
Список основных задач, которые мы решим на протяжении туториала:
- Создание шаблона
React-приложения
с помощью Vite. - Создание компонента для получения приветствия от сервера.
- Установка и настройка
Jest
. - Установка и настройка
Testing Library
. - Тестирование компонента с помощью
Testing Library
:
- Используя стандартные возможности.
- С помощью кастомного рендера.
- С помощью кастомных запросов.
- Тестирование компонента с помощью снимков
Jest
.
Репозиторий с кодом проекта.
Если вам это интересно, прошу под кат.
Создание шаблона
Обратите внимание: для работы с зависимостями я буду использовать Yarn.
Vite
— это продвинутый сборщик модулей (bundler) для JavaScript-приложений
. Он более производительный и не менее кастомизируемый, чем Webpack.
Vite CLI
позволяет создавать готовые к разработке проекты, в том числе, с помощью некоторых шаблонов.
Создаем шаблон React-приложения
:
# react-testing - название проекта
# --template react - используемый шаблон
yarn vite create react-testing --template react
Переходим в созданную директорию и устанавливаем зависимости:
cd react-testing
yarn
Убедиться в работоспособности приложения можно, выполнив команду yarn dev
.
Приводим структуру проекта к следующему виду:
- node_modules
- src
- App.jsx
- main.jsx
- .gitignore
- index.html
- package.json
- vite.config.js
- yarn.lock
{ task: 'setup project', status: 'done' }
Создание компонента
Для обращения к API
мы будем использовать Axios:
yarn add axios
Создаем в директории src
файл FetchGreeting.jsx
следующего содержания:
import { useState } from 'react'
import axios from 'axios'
// пропом компонента является адрес конечной точки
// для получения приветствия от сервера
const FetchGreeting = ({ url }) => {
// состояние приветствия
const [greeting, setGreeting] = useState('')
// состояние ошибки
const [error, setError] = useState(null)
// состояние нажатия кнопки
const [btnClicked, setBtnClicked] = useState(false)
// метод для получения приветствия от сервера
const fetchGreeting = (url) =>
axios
.get(url)
// если запрос выполнен успешно
.then((res) => {
const { data } = res
const { greeting } = data
setGreeting(greeting)
setBtnClicked(true)
})
// если возникла ошибка
.catch((e) => {
setError(e)
})
// текст кнопки
const btnText = btnClicked ? 'Готово' : 'Получить приветствие'
return (
<div>
<button onClick={() => fetchGreeting(url)} disabled={btnClicked}>
{btnText}
</button>
{/* если запрос выполнен успешно */}
{greeting && <h1>{greeting}</h1>}
{/* если возникла ошибка */}
{error && <p role='alert'>Не удалось получить приветствие</p>}
</div>
)
}
export default FetchGreeting
{ task: 'create component', status: 'done' }
Установка и настройка Jest
Устанавливаем Jest
:
yarn add jest
По умолчанию средой для тестирования является Node.js, поэтому нам потребуется еще один пакет:
yarn add jest-environment-jsdom
Создаем в корне проекта файл jest.config.js
(настройки Jest
) следующего содержания:
module.exports = {
// среда тестирования - браузер
testEnvironment: 'jest-environment-jsdom',
}
Для транспиляции кода перед запуском тестов Jest
использует Babel. Поскольку мы будем работать с JSX нам потребуется два "пресета":
yarn add @babel/preset-env @babel/preset-react
Создаем в корне проекта файл babel.config.js
(настройки Babel
) следующего содержания:
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }]
]
}
Настройка runtime: 'automatic'
добавляет React
в глобальную область видимости, что позволяет не импортировать его явно в каждом файле.
Дефолтной директорией с тестами для Jest
является __tests__
. Создаем эту директорию в корне проекта.
Создаем в директории __tests__
файл fetch-greeting.test.jsx
следующего содержания:
test.todo('получение приветствия')
Объекты describe
, test
, expect
и другие импортируются в пространство модуля Jest
. Почитать об этом можно здесь и здесь.
test.todo(name: string)
— это своего рода заглушка для теста, который мы собираемся писать.
Добавляем в раздел scripts
файла package.json
команду для запуска тестов:
"test": "jest"
Выполняем эту команду с помощью yarn test
:
Получаем в терминале нашу "тудушку" и сообщение об успешном выполнении "теста".
Кажется, что можно приступать к тестированию компонента. Почти, есть один нюанс.
Дело в том, что Jest
спроектирован для работы с Node.js
и не поддерживает ESM
из коробки. Более того, поддержка ESM
является экспериментальной и в будущем может претерпеть некоторые изменения. Почитать об этом можно здесь.
Для того, чтобы все работало, как ожидается, нужно сделать 2 вещи.
Можно определить в package.json
тип кода как модуль ("type": "module"
), но это сломает Vite
. Можно изменить расширение файла с тестом на .mjs
, но мне такой вариант не нравится. А можно сообщить Jest
расширения файлов, которые следует обрабатывать как ESM
:
// jest.config.js
module.exports = {
testEnvironment: 'jest-environment-jsdom',
// !
extensionsToTreatAsEsm: ['.jsx'],
}
Также необходимо каким-то образом передать Jest
флаг --experimental-vm-modules
. Существует несколько способов это сделать, но наиболее подходящим с точки зрения обеспечения совместимости с разными ОС является следующий:
- Устанавливаем cross-env с помощью
yarn add cross-env
. - Редактируем команду для запуска тестов:
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
{ task: 'setup jest', status: 'done' }
Установка и настройка Testing Library
Устанавливаем обертку Testing Library
для React
:
yarn add @testing-library/react
Для обеспечения интеграции с Jest
нам также потребуется следующий пакет:
yarn add @testing-library/jest-dom
Для тестирования отправки запроса на сервер и получения от него приветствия необходим фиктивный (mock) сервер. Одним из самых простых решений для этого является msw:
yarn add msw
{ task: 'setup testing library', status: 'done' }
Тестирование компонента с помощью Testing Library
Стандартные возможности
Реализуем тестирование компонента с помощью стандартных возможностей, предоставляемых Testing Library
.
Начнем с импорта зависимостей:
// msw
import { rest } from 'msw'
import { setupServer } from 'msw/node'
// см. ниже
import { render, fireEvent, waitFor, screen } from '@testing-library/react'
// см. ниже
import '@testing-library/jest-dom'
// компонент
import FetchGreeting from '../src/FetchGreeting'
Создаем фиктивный сервер:
const server = setupServer(
rest.get('/greeting', (req, res, ctx) =>
res(ctx.json({ greeting: 'Привет!' }))
)
)
В ответ на GET HTTP-запрос
сервер будет возвращать объект с ключом greeting
и значением Привет!
.
Определяем глобальные хуки:
// запускаем сервер перед выполнением тестов
beforeAll(() => server.listen())
// сбрасываем обработчики к дефолтной реализации после каждого теста
afterEach(() => server.resetHandlers())
// останавливаем сервер после всех тестов
afterAll(() => server.close())
Мы напишем 2 теста:
- для получения приветствия и его рендеринга;
- для обработки ошибки сервера.
Поскольку тестируется один и тот же функционал, имеет смысл сгруппировать тесты с помощью describe
:
describe('получение приветствия', () => {
// todo
})
Начнем с теста для получения приветствия и его рендеринга:
test('-> успешное получение и отображение приветствия', async function () {
// рендерим компонент
// https://testing-library.com/docs/react-testing-library/api/#render
render(<FetchGreeting url='/greeting' />)
// имитируем нажатие кнопки для отправки запроса
// https://testing-library.com/docs/dom-testing-library/api-events#fireevent
//
// screen привязывает (bind) запросы к document.body
// https://testing-library.com/docs/queries/about/#screen
fireEvent.click(screen.getByText('Получить приветствие'))
// ждем рендеринга заголовка
// https://testing-library.com/docs/dom-testing-library/api-async/#waitfor
await waitFor(() => screen.getByRole('heading'))
// текстом заголовка должно быть `Привет!`
expect(screen.getByRole('heading')).toHaveTextContent('Привет!')
// текстом кнопки должно быть `Готово`
expect(screen.getByRole('button')).toHaveTextContent('Готово')
// кнопка должна быть заблокированной
expect(screen.getByRole('button')).toBeDisabled()
})
О запросах типа getByRole
можно почитать здесь, а список всех стандартных запросов можно найти здесь.
О кастомных сопоставлениях (matchers), которыми @testing-library/jest-dom
расширяет объект expect
из Jest
можно почитать здесь.
Перед тем, как приступать к реализации теста для обработки ошибки сервера установим еще один пакет:
yarn add @testing-library/user-event
Данный пакет рекомендуется использовать для имитации пользовательских событий (типа нажатия кнопки) вместо fireEvent
. Почитать об этом можно здесь.
// `it` - синоним `test`
it('-> обработка ошибки сервера', async () => {
// после этого сервер в ответ на запрос
// будет возвращать ошибку со статус-кодом `500`
server.use(rest.get('/greeting', (req, res, ctx) => res(ctx.status(500))))
// рендерим компонент
render(<FetchGreeting url='greeting' />)
// имитируем нажатие кнопки
// рекомендуемый подход
// https://testing-library.com/docs/user-event/setup
const user = userEvent.setup()
// если не указать `await`, тогда `Testing Library`
// не успеет обернуть обновление состояния компонента
// в `act` и мы получим предупреждение в терминале
await user.click(screen.getByText('Получить приветствие'))
// ждем рендеринга сообщения об ошибке
await waitFor(() => screen.getByRole('alert'))
// текстом сообщения об ошибке должно быть `Не удалось получить приветствие`
expect(screen.getByRole('alert')).toHaveTextContent(
'Не удалось получить приветствие'
)
// кнопка не должна быть заблокированной
expect(screen.getByRole('button')).not.toBeDisabled()
})
Запускаем тесты с помощью команды yarn test
:
{ task: 'default testing', status: 'done' }
Кастомный рендер
Предположим, что мы хотим распределить состояние приветствия между несколькими компонентами, например, с помощью провайдера.
Создаем в директории src
файл GreetingProvider.jsx
следующего содержания:
import { createContext, useContext, useReducer } from 'react'
// начальное состояние
const initialState = {
error: null,
greeting: null
}
// константы
const SUCCESS = 'SUCCESS'
const ERROR = 'ERROR'
// редуктор
function greetingReducer(state, action) {
switch (action.type) {
case SUCCESS:
return {
error: null,
greeting: action.payload
}
case ERROR:
return {
error: action.payload,
greeting: null
}
default:
return state
}
}
// создатель операций
const createGreetingActions = (dispatch) => ({
setSuccess(success) {
dispatch({
type: SUCCESS,
payload: success
})
},
setError(error) {
dispatch({
type: ERROR,
payload: error
})
}
})
// контекст
const GreetingContext = createContext()
// провайдер
export const GreetingProvider = ({ children }) => {
const [state, dispatch] = useReducer(greetingReducer, initialState)
const actions = createGreetingActions(dispatch)
return (
<GreetingContext.Provider value={{ state, actions }}>
{children}
</GreetingContext.Provider>
)
}
// кастомный хук
export const useGreetingContext = () => useContext(GreetingContext)
Оборачиваем компонент FetchGreeting
провайдером в файле App.jsx
:
import { GreetingProvider } from './GreetingProvider'
import FetchGreeting from './FetchGreeting'
function App() {
return (
<div className='App'>
<GreetingProvider>
<FetchGreeting url='/greeting' />
</GreetingProvider>
</div>
)
}
export default App
Редактируем FetchGreeting.jsx
:
import { useState } from 'react'
import axios from 'axios'
import { useGreetingContext } from './GreetingProvider'
const FetchGreeting = ({ url }) => {
// извлекаем состояние и операции из контекста
const { state, actions } = useGreetingContext()
const [btnClicked, setBtnClicked] = useState(false)
const fetchGreeting = (url) =>
axios
.get(url)
.then((res) => {
const { data } = res
const { greeting } = data
// !
actions.setSuccess(greeting)
setBtnClicked(true)
})
.catch((e) => {
// !
actions.setError(e)
})
const btnText = btnClicked ? 'Готово' : 'Получить приветствие'
return (
<div>
<button onClick={() => fetchGreeting(url)} disabled={btnClicked}>
{btnText}
</button>
{/* ! */}
{state.greeting && <h1 data-cy='heading'>{state.greeting}</h1>}
{state.error && <p role='alert'>Не удалось получить приветствие</p>}
</div>
)
}
export default FetchGreeting
Для того, чтобы не оборачивать явно каждый тестируемый компонент в провайдеры, из которых он потребляет тот или иной контекст (состояние, тема, локализация и т.д.), предназначен кастомный рендер.
Создаем в корне проекта директорию testing
. В этой директории создаем файл test-utils.jsx
следующего содержания:
import { render } from '@testing-library/react'
import { GreetingProvider } from '../src/GreetingProvider'
// все провайдеры приложения
const AllProviders = ({ children }) => (
<GreetingProvider>{children}</GreetingProvider>
)
// кастомный рендер
const customRender = (ui, options) =>
render(ui, {
// обертка для компонента
wrapper: AllProviders,
...options
})
// повторно экспортируем `Testing Library`
export * from '@testing-library/react'
// перезаписываем метод `render`
export { customRender as render }
Для того, чтобы иметь возможность импортировать кастомный рендер просто из test-utils
необходимо сделать 2 вещи:
- Сообщить
Jest
названия директорий с модулями:
// jest.config.js
module.exports = {
testEnvironment: 'jest-environment-jsdom',
extensionsToTreatAsEsm: ['.jsx'],
// !
moduleDirectories: ['node_modules', 'testing']
}
- Добавить синоним пути в файле
jsconfig.json
(создаем этот файл в корне проекта):
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"test-utils": [
"./testing/test-utils"
]
}
}
}
Для TypeScript-проекта
синонимы путей (и другие настройки) определяются в файле tsconfig.json
.
Редактируем файл fetch-greeting.test.jsx
:
// импортируем стандартные утилиты `Testing Library` и кастомный рендер
import { render, fireEvent, waitFor, screen } from 'test-utils'
Запускаем тест с помощью yarn test
и убеждаемся в том, что тесты по-прежнему выполняются успешно.
{ task: 'testing with custom render', status: 'done' }
Кастомные запросы
Что если нам оказалось недостаточно стандартных запросов, предоставляемых Testing Library
? Что если мы, например, хотим получать ссылку на DOM-элемент
с помощью атрибута data-cy
? Для этого предназначены кастомные запросы.
Создаем в директории testing
файл custom-queries.js
следующего содержания:
import { queryHelpers, buildQueries } from '@testing-library/react'
const queryAllByDataCy = (...args) =>
queryHelpers.queryAllByAttribute('data-cy', ...args)
const getMultipleError = (c, dataCyValue) =>
`Обнаружено несколько элементов с атрибутом data-cy: ${dataCyValue}`
const getMissingError = (c, dataCyValue) =>
`Не обнаружен элемент с атрибутом data-cy: ${dataCyValue}`
// генерируем кастомные запросы
const [
queryByDataCy,
getAllByDataCy,
getByDataCy,
findAllByDataCy,
findByDataCy
] = buildQueries(queryAllByDataCy, getMultipleError, getMissingError)
// и экспортируем их
export {
queryByDataCy,
queryAllByDataCy,
getByDataCy,
getAllByDataCy,
findByDataCy,
findAllByDataCy
}
Далее кастомные запросы можно внедрить в кастомный рендер:
// test-utils.js
import { render, queries } from '@testing-library/react'
import * as customQueries from './custom-queries'
const customRender = (ui, options) =>
render(ui, {
wrapper: AllProviders,
// !
queries: { ...queries, ...customQueries },
...options
})
Определяем атрибут data-cy
у заголовка в компоненте FetchGreeting
:
{state.greeting && <h1 data-cy='heading'>{state.greeting}</h1>}
И получаем ссылку на этот элемент в тесте с помощью кастомного запроса:
const { getByDataCy } = render(<FetchGreeting url='/greeting' />)
expect(getByDataCy('heading')).toHaveTextContent('Привет!')
Запускаем тест с помощью yarn test
:
И получаем ошибку.
Ни в документации Testing Library
, ни в документации Jest
данная ошибка не описывается. Как видим, она возникает в файле node_modules/@testing-library/dom/dist/get-queries-for-element.js
:
function getQueriesForElement(element, queries = defaultQueries, initialValue = {}) {
return Object.keys(queries).reduce((helpers, key) => {
// получаем запрос по ключу
const fn = queries[key];
// и передаем в запрос элемент в качестве аргумента
// здесь возникает ошибка
// `fn.bind не является функцией`
helpers[key] = fn.bind(null, element);
return helpers;
}, initialValue);
}
Это наводит на мысль, что проблема заключается в наших кастомных запросах. Давайте на них взглянем:
// test-utils.jsx
console.log(customQueries)
Запускаем тест:
Видим ключ __esModule
со значением true
. Свойство __esModule
функцией не является, поэтому при попытке вызова bind
на нем выбрасывается исключение. Но откуда оно взялось в нашем модуле?
Коротко о главном:
test-utils.jsx
является модулем для тестирования;Jest
автоматически создает "моковые" версии таких модулей — объекты заменяются,API
сохраняется;- перед созданием мока код модуля транспилируется с помощью
Babel
; Jest
запускается в режиме поддержкиESM
, поэтомуBabel
добавляет свойство__esModule
в каждый мок.
Одним из самых простых способов решения данной проблемы является запрос оригинального модуля (без создания его моковой версии) с помощью метода requireActual объекта jest
.
Для того, чтобы иметь возможность использовать этот объект в ESM
, его следует импортировать из @jest/globals
:
yarn add @jest/globals
import { jest } from '@jest/globals'
// import * as customQueries from './custom-queries'
const customQueries = jest.requireActual('./custom-queries')
Запускаем тест. Теперь все работает, как ожидается.
{ task: 'testing with custom queries', status: 'done' }
Тестирование компонента с помощью снимков Jest
Конечно, можно исследовать каждый DOM-элемент
компонента по отдельности с помощью сопоставлений типа toHaveTextContent
, но, согласитесь, что это не очень удобно. Легко можно пропустить какой-нибудь элемент или атрибут.
Для исследования текущего состояния всего UI
за один раз предназначены снимки (snapshots).
На самом деле, в нашем распоряжении уже имеется все необходимое для тестирования компонента с помощью снимков. Одним из значений, возвращаемых методом render
является container, который можно передать в метод expect
и вызвать метод toMatchSnapshot:
describe('получение приветствия', () => {
test('-> успешное получение и отображение приветствия', async function () {
// получаем контейнер
const { container, getByDataCy } = render(<FetchGreeting url='/greeting' />)
// тестируем текущее состояние `UI` с помощью снимка
expect(container).toMatchSnapshot()
fireEvent.click(screen.getByText('Получить приветствие'))
await waitFor(() => screen.getByRole('heading'))
// состояние `UI` изменилось, поэтому нужен еще один снимок
expect(container).toMatchSnapshot()
expect(getByDataCy('heading')).toHaveTextContent('Привет!')
expect(screen.getByRole('button')).toHaveTextContent('Готово')
expect(screen.getByRole('button')).toBeDisabled()
})
it('-> обработка ошибки сервера', async () => {
server.use(rest.get('/greeting', (req, res, ctx) => res(ctx.status(500))))
// получаем контейнер
const { container } = render(<FetchGreeting url='greeting' />)
// снимок 1
expect(container).toMatchSnapshot()
const user = userEvent.setup()
await user.click(screen.getByText('Получить приветствие'))
await waitFor(() => screen.getByRole('alert'))
// снимок 2
expect(container).toMatchSnapshot()
expect(screen.getByRole('alert')).toHaveTextContent(
'Не удалось получить приветствие'
)
expect(screen.getByRole('button')).not.toBeDisabled()
})
})
Запускаем тест:
При первом выполнении теста, в котором используются снимки, Jest
генерирует снимки и складывает их в директорию __snapshots__
в директории с тестом. В нашем случае запуск теста привел к генерации файла fetch-greeting.test.jsx.snap
следующего содержания:
exports[`получение приветствия -> обработка ошибки сервера 1`] = `
<div>
<div>
<button>
Получить приветствие
</button>
</div>
</div>
`;
exports[`получение приветствия -> обработка ошибки сервера 2`] = `
<div>
<div>
<button>
Получить приветствие
</button>
<p
role="alert"
>
Не удалось получить приветствие
</p>
</div>
</div>
`;
exports[`получение приветствия -> успешное получение и отображение приветствия 1`] = `
<div>
<div>
<button>
Получить приветствие
</button>
</div>
</div>
`;
exports[`получение приветствия -> успешное получение и отображение приветствия 2`] = `
<div>
<div>
<button
disabled=""
>
Готово
</button>
<h1
data-cy="heading"
>
Привет!
</h1>
</div>
</div>
`;
Как видим, снимок правильно отражает все изменения состояния UI
компонента.
Снова запускаем тест:
{ task: 'snapshot testing', status: 'done' }
Парочка полезных советов:
- для обновления снимка следует передать флаг --updateSnapshot или просто
-u
при вызовеJest
:yarn test -u
; - для указания тестов для выполнения или снимков для обновления при вызове
Jest
можно передать флаг --testPathPattern со значением директории с тестами (в виде строки или регулярного выражения):yarn test -u --testPathPattern=components/fetchGreeting
.
Для того, чтобы иметь возможность импортировать статику (изображения, шрифты, аудио, видео и т.д.) в тестируемых компонентах, необходимо реализовать кастомный трансформер для Jest
.
Создаем в директории testing
файл file-transformer.js
следующего содержания:
// формат `CommonJS` в данном случае является обязательным
const path = require('path')
module.exports = {
process: (sourceText, sourcePath, options) => ({
code: `module.exports = ${JSON.stringify(path.basename(sourcePath))}`
})
}
И настраиваем трансформацию в файле jest.config.js
:
module.exports = {
testEnvironment: 'jest-environment-jsdom',
extensionsToTreatAsEsm: ['.jsx'],
moduleDirectories: ['node_modules', 'testing'],
// !
transform: {
// дефолтное значение, в случае кастомизации должно быть указано явно
'\\.[jt]sx?$': 'babel-jest',
// трансформация файлов
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/testing/file-transformer.js'
}
}
Пожалуй, это все, что я хотел рассказать о тестировании React-компонентов
с помощью Jest
и Testing Library
. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время.
Благодарю за внимание и happy coding!