Тестирование React. Часть 1: testing-library

Всем привет. Я - Петр Цой. Ищу первую работу на React. В качестве моего резюме выступает сайт petrtcoi.com. Ссылка на GitHub.

В прошлой статье я описал, как настраивал смену темы сайта через CSS. Пришло время описать, как писал тесты для этого. В проекте использованы @testing-library, @playwright и storybook. Здесь рассматривается первая библиотека.

Тестирование с @testing-library

В качестве основной библиотеки я использую @testing-library. Она довольно удобная и хорошо подходит для создания кода в стиле TDD. Один из создателей библиотеки - Kent C. Dodds - проповедует написание тестов, которые не проверяют внутреннюю реализацию объекта и оценивают лишь его внешние проявления (черный ящик). Такой подход позволяет создавать более надежные тесты и избегать ложно-положительных и ложно-отрицательных результатов. Соответственно, и я попытался следовать подходам, заложенными создателями библиотеки. С подробной аргументацией позиции Kent C. Dodds можно ознакомиться в его же статье.

Начальная настройка

На сайте NextJS есть инструкция по установке и настройки библиотеки. После установки нужных пакетов и создания файла jest.config.js вы получаете готовый к работе инструмент.

В процессе работы я внес небольшие изменения в настройки для большего удобства.

Во-первых, файл package.json прописал скрипты для быстрого запуска тестов.

"scripts":{ ... "test": "jest --watch", "coverage": "npx jest --watchAll=false --coverage" }

Первый нужен для запуска тестов в режиме watch. Второй - для анализа покрытия кода тестами.

Во-вторых, создал файл setupTests.js с содержимым из одной строки.

// /setupTests.js import '@testing-library/jest-dom';

Ее добавил в jest.config.js. В противном случае, с этой строки надо было бы начинать каждый тест. Получилось так:

// ./jest.config.js ... const customJestConfig = { moduleDirectories: ['node_modules', '<rootDir>/'], testEnvironment: 'jest-environment-jsdom', setupFilesAfterEnv: ['./setupTests.js'] }

Также при запуске npm run coverage скрипт анализировал еще и файлы *.stories.tsx (про настройку Storybook будет отдельная статья) и файлы __snapshots__. Чтобы этого избежать, добавил явную запись о том, какие файлы анализировать. Также добавил и указание формата файлов для тестирования, чтобы Jest не пытался запускать e2e тесты, когда нужно только unit-test.

// ./jest.config.js ... const customJestConfig = { moduleDirectories: ['node_modules', '<rootDir>/'], testEnvironment: 'jest-environment-jsdom', setupFilesAfterEnv: ['./setupTests.js'], testMatch: [ "**/*.test.*" ], collectCoverageFrom: [ "<rootDir>/src/**/*.tsx", "!<rootDir>/src/**/*.stories.tsx", "!**/__snapshots__/**" ] }

Создание тестов

Теперь все готово к тому, чтобы начать писать первые тесты. Файлы с тестами размещаются в той же папке, что и тестируемый компонент. Если это интеграционный тест, то соответствующий файл размещается в папке уровнем выше. И т.д. вплоть до корневой папки src/__tests__.

Базовые тесты

К базовым я отношу тесты, которые просто проверяют сам факт того, что требуемые компоненты отрисованы на странице и имеют ожидаемые характеристики.

Для примера рассмотрим компонент , располагающийся в верхней части сайта. В этом компоненте у нас есть 3 элемента:

  • Логотип React слева
  • Мое имя по центру
  • "Бургер" для вызова меню справа.
Тестирование React. Часть 1: testing-library

Тесты для них выглядят следующим образом:

// /src/components/Layout/Header/Header import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Header from './Header' describe('Header', () => { beforeEach(() => { render(<Header />) }) test('Выводится ссылка на главную страницу на странице', () => { const linkToMainPage = screen.getByRole('link', {name: /react logo/i }) expect(linkToMainPage).toBeVisible() expect(linkToMainPage).toHaveAttribute('href', '/') }) test('Выводится логотип React', () => { const reactLogo = within((screen.getByRole('link', {name: /react logo/i }))).getByTestId("react logo") expect(reactLogo).toBeVisible() }) test('Выводится Заголовок в шапке', () => { expect(screen.getByRole('heading', { name: /petr tcoi/i, level: 1 })).toBeVisible() }) test('Есть бургер, кнопка для открытия меню', () => { expect(screen.getByRole('button', { name: /открыть меню/i })).toBeVisible() }) })

Логотип React проверяется именно внутри ссылки на главную страницу. Здесь для поиска лого я использовал метод getByTestId, к которому авторы библиотеки настоятельно рекомендуют прибегать лишь в крайних случаях. Первым выбором всегда должен быть getByRole. Использование этого метода стимулирует писать более грамотный HTML-код и делает сам тест более устойчивым к будущим правкам проверяемого объекта.

В случае с логотипом я "сжульничал", так как не смог определить подходящую роль для SVG-иконки. Но нарушение посчитал не критичным для этой ситуации.

Для удобства вынес поиск элементов в отдельные функции.

// /src/components/Layout/Header/Header describe('Header', () => { const getLinkToMainPage = () => screen.getByRole('link', {name: /react logo/i }) const getNavbarTitle = () => screen.getByRole('heading', { name: 'Petr Tcoi', level: 1 }) const getBurgerButton = () => screen.getByRole('button', { name: /открыть меню/i }) beforeEach(() => { render(<Header />) }) test('Выводится ссылка на главную страницу на странице', () => { expect(getLinkToMainPage()).toBeVisible() expect(getLinkToMainPage()).toHaveAttribute('href', '/') }) test('эта страница вклчюает в себя логотип React', () => { const reactLogo = within((getLinkToMainPage())).getByTestId(/react logo/i ) expect(reactLogo).toBeVisible() }) test('Выводится Заголовок в шапке', () => { expect(getNavbarTitle()).toBeVisible() }) test('Есть бургер, кнопка для открытия меню', () => { expect(getNavbarTitle()).toBeVisible() }) })

Таким образом текст самих тестов выглядит более разборчивым и их будет проще поддерживать. Достаточно будет в начале файле поменять функцию поиска элемента, если что-то будет меняться.

Тестируем взаимодействие

Проверим работу "бургера". Для тестирования взаимодействия с компонентом используется метод userEvent. Она работает в асинхронном режиме и не требует никаких дополнительных оберток типа act(), как было ранее.

Так как в этом файле тестируется только сам компонент, как есть, то здесь мы проверяем только то, что кнопка исчезает при клики по ней. Выскакивает ли при этом боковое меню, мы проверим в интеграционном тесте на уровне выше.

"Бургер" должно закрывать выскакивающее боковое меню, но, если у пользователя очень большой экран, то боковое меню может не закрыть "бургер" и он так и останется висеть на экране.

В простейшем случае код "бургера" выглядел бы так:

// /src/components/Layout/Header/Header .... <button aria-label="Открыть меню" style={{visibility: menuStatus === MenuStatus.open ? false : true }} onClick={ () => setMenuStatus(MenuStatus.open) } className={styles.burgerbutton} > <BurgerMenuIcon size={ 25 } className={ "svg__button" } /> </button> ...

При тестировании мы бы просто убедились, что "бургер" перестал быть видимым:

test('Бургер исчезает после клика по нему', async () => { await userEvent.click(getBurgerButton()) expect(getBurgerButton()).not.toBeVisible() })

Но это решение мне не понравилось визуально: "бургер" исчезает сразу же после клика и боковое меню не успевает закрыть его. То есть, нужна задержка перед исчезновением "бургера". Этого можно было бы добиться через внесение дополнительной логики в компонент. Ставить задержку setTimeout после клика по "бургеру" на 1 секунду и, по истечении этой секунды, уже ставить специальное свойство hideBurger в true.

При тестировании нужно было бы, соответственно, также ставить таймер после вызова действия пользователя. Что-то вроде:

await new Promise((r) => setTimeout(r, 1000));

Чтобы не усложнять компонент, я решил вопрос только силами CSS. Код "бургера" имеет следующий вид:

<div aria-label="Открыть меню" role="button" data-state-hide={ menuStatus === MenuStatus.open ? "true" : "false" } onClick={ () => setMenuStatus(MenuStatus.open) } > <BurgerMenuIcon size={ 25 } className={ "svg__button" } /> </div>

Атрибут "data-state-hide" в начале имеет слово "state" для удобства, чтобы было понятно, что он отвечает за то или иное изменение в поведении элемента. В данном случае он означает, что элемент нужно скрыть. Для этого в стилях прописываем:

/* /src/assets/styles/states.css */ [data-state-hide="true"] { opacity: 0; visibility: hidden; transition: opacity 1s, visibility 1s; }

Opacity используется для того, чтобы "бургер" исчезал постепенно. Свойство visibility добавлено здесь для того, чтобы курсор не менялся, когда находится над наводимой кнопкой. Теперь для тестирования нам достаточно убедиться, что "бургер" получает значение true для data-state-hide.

test('Бургер в меню имеет атрибут data-state-hide = false', () => { expect(getBurgerButton()).toHaveAttribute('data-state-hide', 'false') }) test('Бургер в меню имеет атрибут data-state-hide = true после клика', async () => { await userEvent.click(getBurgerButton()) expect(getBurgerButton()).toHaveAttribute('data-state-hide', 'true') })

Тестирование связки компонентов

В папке Layout разместил тест, проверяющий связку "бургера" и Бокового меню. Скрытие/открытие меню реализовано также, как скрытие "бургера" - через атрибут data-state-**. Тест довольно простой и выглядит так:

// /src/components/Layout/TogglePopupMenuByBurger.test.tsx ... describe('Toggle PopupMenu by Burger ', () => { const getPopupMenu = () => screen.getByRole('navigation', { name: /popup меню/i }) const getOpenMenuButton = () => screen.getByRole('button', { name: /открыть меню/i }) const getCloseMenuButton = () => screen.getByRole('button', { name: /закрыть меню/i }) beforeEach(() => render(<Header />)) test('Popup меню скрыто и имеет статус закрыто', () => { expect(getPopupMenu().dataset.statePopupmenuStatus).toEqual(MenuStatus.closed) }) test('Клик по бургеру меняет статус меню на открыто', async () => { await userEvent.click(getOpenMenuButton()) expect(getPopupMenu().dataset.statePopupmenuStatus).toEqual(MenuStatus.open) }) test('Клик по ClosedIcon меняет статус на закрыто', async () => { await userEvent.click(getOpenMenuButton()) await userEvent.click(getCloseMenuButton()) expect(getPopupMenu().dataset.statePopupmenuStatus).toEqual(MenuStatus.closed) }) })

Стили, описывающие работу PopupMenu относятся только к нему, а потому размещены в той же папке, что и сам компонент.

/* /src/components/Layout/PopupMenu/popupmenu.module.css ... .popupmenu[data-state-popupmenu-status="open"] { opacity: 100; margin-right: 0px; transition: margin var(--basic-duration), background-color var(--basic-duration); } ...

Таже проверяем только атрибут, в данном случае data-state-popupmenu-status.

Тестируем смену темы

Смена темы на сайте у нас происходит через установку соответствующего значения атрибута data-theme в теге html.

Цвета на сайте задаются через CSS-переменные и выглядят они так:

/* /src/assets/styles/variables.css */ :root { --color-main: #e6e5e5; --color-grey: #868687; ... } [data-theme="light"] { --color-main: #000000; --color-background: #FFFFFF; }

Смена темы происходит через вспомогательную функцию:

// /src/assets/utls/setTheme.ts import { ThemeColorSchema } from "../types/ui.type" const setUiTheme = (theme: ThemeColorSchema) => { document.documentElement.setAttribute("data-theme", theme) } export { setUiTheme }

Средствами @testing-library мы не можем проверить значения атрибутов в теге . Для этих целей будет использован фреймворк Playwright (работу с ним я опишу в следующей статье).

Здесь же мы можем проверить только сам факт того, что функция смены вызывается и делает это с верными параметрами.

Файл с тестом выглядит так. Сначала проверяем, что все части переключателя выводятся.

// /src/components/Layout/PopupMenu/ThemeSwitcher/ThemeSwitcher.tsx import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ThemeSwitcher from './ThemeSwitcher' import { ThemeColorSchema } from '../../../assets/types/ui.type' const getSwitcher = () => screen.getByRole('switch', { name: /переключение темы/i }) const getCheckbox = () => getSwitcher().firstChild as HTMLInputElement describe('ThemeSwitcher', () => { beforeEach(() => render(<ThemeSwitcher />)) test('Выводит элемент swicth', () => { expect(getSwitcher()).toBeVisible() }) test('Выводит описания заголовок для светлой темы - "светлая"', () => { expect(screen.getByText(/cветлая/i)).toBeVisible() }) test('Выводит описания заголовок для темной темы - "темная"', () => { expect(screen.getByText(/темная/i)).toBeVisible() }) test('Checkbox в начальном состоянии "checked"', () => { expect(getCheckbox()).toBeChecked() }) ... })

После уже добавляем проверку работы самого переключателя. Для этого "мокаем" функцию переключения темы.

// /src/components/Layout/PopupMenu/ThemeSwitcher/ThemeSwitcher.tsx ... import { setUiTheme } from '../../../assets/utils/setUiTheme' jest.mock('../../../assets/utils/setUiTheme.ts', () => ({ ...(jest.requireActual('../../../assets/utils/setUiTheme.ts')), setUiTheme: jest.fn() })) ... describe('ThemeSwitcher', () => { .... afterEach(() => { jest.clearAllMocks() }) .... test('Клик по checkbox вызывает метод setUiTheme с аргументов Theme.light', async () => { userEvent.click(getCheckbox()) await waitFor(() => { expect(getCheckbox()).not.toBeChecked() }) expect(setUiTheme).toBeCalledTimes(1) expect(setUiTheme).toBeCalledWith(ThemeColorSchema.light) }) test('Два клика по checkbox вызовут setUiTheme два раза и последний аргумент будет Theme.dark', async () => { userEvent.click(getCheckbox()) await waitFor(() => { expect(getCheckbox()).not.toBeChecked() }) userEvent.click(getCheckbox()) await waitFor(() => { expect(getCheckbox()).toBeChecked() }) expect(setUiTheme).toBeCalledTimes(2) expect(setUiTheme).toBeCalledWith(ThemeColorSchema.dark) }) })

Тест довольно простой. Некоторую сложность может вызывать проверка checkbox: он отрабатывается асинхронно, поэтому при работе с ним требуется добавлять ожидание, когда он переключится - await waitFor(() => { expect(getCheckbox()).not.toBeChecked() }).

Заключение

Библиотека @testing-library позволяет довольно легко составлять тесты и ей удобно пользоваться при создании новых компонентов.

Отдельного упоминания заслуживает побочное следствие, вытекающее из требования максимально использовать поиск объектов по их ролям - getByRole - в противоположность более простому на начальном этапе getByTestId. С одной стороны, это требует дополнительной работы по определению подходящей роли элемента. С другой - мотивирует создавать более качественную и выразительную верстку документа с использованием соответствующих тегов HTML вместо универсальных

.

Хорошим примером здесь является использование наивного тега вместо .

Самым большим ограничением подобного тестирования является работа метода render, который "не видит", что скрыто за тем или иным CSS-классом. Поэтому некоторый функционал сайта можно проверить только по косвенным признакам. Например, как в случае с "бургером", мы проверяем лишь переключение нужного атрибута, но не имеем возможности проверить, как все происходит на самом деле. То же касается и смены цветовой схемы сайта.

Для решения этой задачи я использовал уже end-to-end тесты с помощью библиотеки Playwright. Следующая запись будет просвещена ей.

22
2 комментария

Желаю удачи с поиском!

Держите стажировку
https://t.me/myitjob/84

Спасибо!

1