React: тестируем компоненты с помощью Jest и Testing Library

от автора

Привет, друзья!

В данном туториале я покажу вам, как тестировать компоненты на React с помощью Jest и Testing Library.

Список основных задач, которые мы решим на протяжении туториала:

  1. Создание шаблона React-приложения с помощью Vite.
  2. Создание компонента для получения приветствия от сервера.
  3. Установка и настройка Jest.
  4. Установка и настройка Testing Library.
  5. Тестирование компонента с помощью Testing Library:
    1. Используя стандартные возможности.
    2. С помощью кастомного рендера.
    3. С помощью кастомных запросов.
  6. Тестирование компонента с помощью снимков 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. Существует несколько способов это сделать, но наиболее подходящим с точки зрения обеспечения совместимости с разными ОС является следующий:

  1. Устанавливаем cross-env с помощью yarn add cross-env.
  2. Редактируем команду для запуска тестов:

"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 вещи:

  1. Сообщить Jest названия директорий с модулями:

// jest.config.js module.exports = {   testEnvironment: 'jest-environment-jsdom',   extensionsToTreatAsEsm: ['.jsx'],   // !   moduleDirectories: ['node_modules', 'testing'] }

  1. Добавить синоним пути в файле 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/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *