Привет, друзья!
В данном туториале я покажу вам, как тестировать компоненты на 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!
ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/670480/

Добавить комментарий