Тестрирование в React
Last updated
Last updated
Как тестировать React-компоненты? Какую библиотеку использовать? Как тестировать компоненты, которые берут данные из Redux, а не из пропсов? Как тестировать компоненты, в которых используется роутинг с помощью React-router-dom? Что делать, если в компоненте есть асинхронный код?
Привет, Хабр, меня зовут Даниил, я выпускник Elbrus Bootcamp. Это вопросы в моей голове, когда на работе меня впервые попросили покрыть тестами компонент. Я, разумеется, стал гуглить тестирование React-компонентов в связке с Redux и React-router-dom, и понял, что в сети есть много ответов на вопрос, зачем нужно тестирование, но мало кто объясняет, как написать тесты. А если и объясняет, то в общих чертах на абстрактных примерах. Мне не хватало статьи, вооружившись которой, начинающий разработчик мог бы выполнить тест на реальном продукте. Поэтому я решил написать ее сам.
Статья предназначена для таких же, как я: разработчиков, которые пришли на свою первую работу и впервые столкнулись с необходимостью написать тесты. Более опытных коллег прошу проверить мои выводы, дать советы и замечания.
Тест — это функция, которая принимает два аргумента: название теста и callback-функцию с логикой.
Внутри callback необходимо вызвать функцию render, которая импортируется из библиотеки и принимает тестируемый React-компонент.
Дальше идет главная конструкция для теста — функция expect, которую можно описать так:
expect(<реальное состояние>).toBe(<ожидаемое состояние>);
В данном случае нам необходимо убедиться, что выпадающее меню с кошельками <реальное состояние>
было отрисовано в HTML-разметке <ожидаемое состояние>.
Чтобы получить этот элемент разметки, необходимо воспользоваться методами объекта screen, который мы также импортировали из библиотеки. Он содержит различные методы для взаимодействия с DOM-деревом. В данном случае мы вызываем метод «getByLabelText», который осуществляет поиск элемента, который ассоциирован с тегом <label>
, в котором есть текст «From».
Выполнив поиск элемента, дописываем логическое выражение. В данном случае вызовом функции .toBeInTheDocument()
.
Литературный смысл теста можно описать так: «после рендера компонента на странице отображается элемент с лейблом “From”».
Ниже будет более детальная информация по поисковым методам объекта screen и их отличиям.
Работа с DOM-деревом
У react-testing-library есть две ключевые особенности. Во-первых, она позволяет абстрагироваться от логики внутри компонента. Во-вторых, отслеживает состояние элементов реального DOM. Это означает, что библиотека старается максимально имитировать пользовательское поведение и использовать только то, что может видеть он.
По описанию примера теста выше можно увидеть, что библиотека ищет элемент в реальном DOM-дереве и исследует его состояние. В данном случае — что этот элемент существует.
А что насчет пункта с абстрагированием от логики внутри компонента? Есть функция расчета комиссии за перевод. Мы можем предположить, что расчет комиссии содержит логику, НО с данной библиотекой мы можем проверить только то, что после расчёта комиссии в DOM-дереве произошли изменения, которые мы ожидаем. То есть мы не проверяем, что state компонента изменился, как делали бы, используя другие библиотеки, а смотрим, что сумма комиссии на страничке — то, что видит пользователь — соответствует нашим ожиданиям. Внутри компонента логика может быть любой.
Мы знаем, что комиссия равна 1% от суммы перевода:
Литературное описание теста: «после того, как пользователь ввел значение “1” в поле ввода с лейблом “Amount”, поле ввода с лейблом “Fee” будет равно 0.01».
В примере выше использовался еще один важный объект, предоставляемый библиотекой React Testing Library — userEvent. Он предоставляет набор методов для взаимодействия с элементами DOM, которые максимально приближены к реальному поведению пользователя.
Он может что-то напечатать:
userEvent.type(<Элемент с которым взаимодействует юзер>, «текст который он вводит»)
может нажать на кнопку:
userEvent.click(<Элемент с которым взаимодействует юзер>)
и так далее.
Для успешной работы с библиотекой нужно перестать быть разработчиком, который думает о логике внутри своего кода, и стать обычным юзером, который как-то взаимодействует с тем, что видит на страничке, и получает результат своего взаимодействия.
Поиск элемента
Теперь, когда основная концепция описана, углубимся в синтаксис, особенности и тонкости.
Поисковые методы по DOM-дереву, которые предоставляет screen, делятся на три категории:
1. getBy — поиск элемента на странице;
2. queryBy — поиск элемента, которого нет на странице;
3. findBy — поиск элемента на странице, который зависит от асинхронного кода.
Если getBy не вызывает вопросов и используется чаще всего, то две другие категории требуют внимания. Начнем с queryBy. В нашем примере есть валидация операции отправки денег. Если валидация не прошла, пользователю выводится ошибка:
Значит, когда пользователь только зашел на страницу с нашим компонентом, он не должен видеть ошибку. Чтобы это проверить, мы должны использовать queryBy:
Также стоит обратить внимание, что для любого утверждения после expect
не существует обратного утверждения (например, NotToBeInTheDocument
). Все отрицания выполняются с помощью конструкции .not
перед выражением. Если вы попробуете выполнить поиск, используя getBy, то получите ошибку в синтаксисе теста — он не найдет элемент.
Следующая категория — findBy для работы с асинхронным кодом. Загрузка имени юзера происходит асинхронно:
А отрисовка элемента происходит после получения данных о юзере:
Значит, тут необходимо использовать findBy:
Обратите внимание, что если нам необходимо дождаться выполнения асинхронной функции, то и callback-функция, передаваемая в тест, должна быть асинхронной.
Вот удобная таблица методов поиска:
getBy
queryBy
findBy
getByText
queryByText
findByText
getByRole
queryByRole
findByRole
getByLabelText
queryByLabelText
findByLabelText
getByPlaceholderText
queryByPlaceholderText
findByPlaceholderText
getByAltText
queryByAltText
findByAltText
getByDisplayValue
queryByDisplayValue
findByDisplayValue
getByTestId
queryByTestId
findByTestId
getByTitle
queryByTitle
findByTitle
Подробно хочется остановится только на двух:
Поиск по роли (getByRole, queryByRole, findByRole). Помогает найти элемент по его логической роли в документе. Вот некоторые роли:
‘combobox’ - тег <select />
‘button’ - тег <button />
или <input type=«submit» />
В нашем примере для поиска кнопки отправки используется getByRole:
Поиск по testId. В коде вы можете указать любому компоненту data-атрибут testId и использовать это значение при поиске элемента:
У всех методов поиска есть варианты поиска всех элементов, соответствующих условию на странице:
getAllByText
queryAllByPlaceholderText
findAllByTestId
и т.д. для всех поисковых методов
Логические выражения
Следует уделить внимание и правильному использованию логических выражений. Они проверяют выполнение теста:
Приведу пару примеров использования разных методов функции expect:
toBeNull:
expect(screen.queryByText(/Hello/)).toBeNull();
ожидание, что компонент будет равен null
toBeInTheDocument:
expect(await screen.findByText(/Hello/)).toBeInTheDocument()
ожидание, что компонент есть в DOM
toBeTruthy:
expect(screen.getByLabelText<HTMLInputElement>('Fee').disabled).toBeTruthy();
ожидание, что поле ввода с лейблом "Fee" будет недоступно для пользовательского ввода
toHaveTextContent:
expect(screen.getByText<HTMLSpanElement>(/balance/i)).toHaveTextContent('10');
ожидание, что в текстовом элементе, который содержит текст "balance", будет текст '10'
toBe:
expect(state[0].balance).toBe(4.95);
ожидание, что одна цифра будет равна другой цифре (в данном случае баланс кошелька из state будет равен 4.95)
В чем преимущество использования максимально подходящих под ситуацию утверждений, хотя всегда есть желание поставить .toBe()
и не заморачиваться?
Их два: это более понятный код утверждений для прочтения другим членом команды, даже незнакомым с написанием кода и тестов, и более понятные ошибки в логах, если тест провалился.
userEvent и fireEvent
click - нажатие на элемент;
dblClick - двойное нажатие на элемент;
type - печать текста;
clear - очистить поле ввода (только <input/>
и <select/>
);
tab - нажатие на tab;
hover - наведение мышки;
unhover - снятие наведения мышки;
upload - загрузка файла;
selectOptions - выбрать из выпадающего списка (<select />
);
deselectOptions -убрать выбор ;
paste - вставить из буфера обмена;
keyboard - имитация нажатия клавиш;
Возьмем метод type у userEvent и сравним с тем же событием в fireEvent:
userEvent:
userEvent.type(screen.getByLabelText<HTMLInputElement>('Amount'), '10')
fireEvent:
fireEvent.change(screen.getByLabelText<HTMLInputElement>('Amount'), {target: { value: '10' }});
Redux
Как тестировать компонент, если он берет данные из Redux-стора? Нужно сделать настоящий store, только внутрь передать заранее написанные данные:
Функция renderWithRedux принимает в себя компонент, который нужно отрисовать, и данные, с которыми этот элемент будет отрисован. Внутри себя она создает store и оборачивает компонент тегом <Provider/>
, куда и передает созданный store. Обратите внимание: функция возвращает не только рендер компонента, но и сам store.
Разберем на конкретных примерах.
Литературное описание теста: «Значение поля ввода с лейблом "From" по умолчанию будет равно "id" первого кошелька».
В функцию renderWithRedux передается тестируемый компонент и данные, а возвращает функция store. Это обычный стор Redux, из него мы получаем state и можем отслеживать, как он меняется в результате различных действий. Например:
Литературное описание теста: «После того, как пользователь ввел в поле Amount строку "5" и нажал на кнопку, выполняется отправка денег, в результате которой баланс одного кошелька будет равен "4.95", а другого — "12"».
ВАЖНО! Все тесты работают с одним стором. Если вы в первом тесте изменили стор (например, для проверки отправили деньги с одного кошелька на другой), то во всех последующих тестах баланс кошельков будет отличаться от исходного.
Функция renderWithRedux может быть написана вами по-другому. Реализация не важна, главное, чтобы она оборачивала компонент провайдером со стором. Однако реализация в этой статье написана не мной и применяется повсеместно. Своего рода стандарт. Надеюсь, в какой-то момент она станет частью библиотеки.
Router
Имитировать роутинг вам потребуется не только для проверки адреса, куда переходит пользователь, но и если используете navigate в компоненте:
В нашем примере есть три роута:
"/" — страница "Welcome";
"/send" — страница "Send";
"/success" — страница "Success";
Для тестирования этого примера нужно выполнить рендер компонентов не только с роутингом, но и с Redux. Так выглядит функция renderWithReduxAndRouter:
Компонент, обеспечивающий роутинг, в примере называется <NavBar/> и выглядит так:
Тесты, которые проверяют все три роута:
Литературное описание теста: "Первая страница, которую видит юзер — страница приветствия".
Литературное описание теста: "После нажатия на ссылку "Send" пользователь переходит на страницу отправки средств".
Литературное описание теста: "После отправки средств пользователь переходит на страницу успеха".
На последнем примере можно увидеть, как перед отправкой были выбраны кошельки 4 и 5. Все потому, что у нас уже есть тест, который изменяет балансы кошельков. А стор у нас единый для всех тестов. Значит, нужно использовать балансы кошельков, которые до этого не участвовали в тестах, либо учитывать эти изменения в других тестах.
Заключение
Писать тесты — не то же самое, что писать код. Главное отличие состоит в необходимости имитировать различные сущности: роутер, пользовательское поведение, данные, библиотеки. Именно в этой области и возникают основные сложности при написании тестов.
Для наглядности я написал небольшое : меню навигации, страничка с приветствием, на страничке Send форма отправки денег с одного кошелька на другой, с возможностью менять кошельки, вводить сумму перевода с расчетом комиссии, с выводом ряда ошибок. После успешной отправки средств пользователь попадает на экран успеха.
Для этой статьи я выбрал библиотеку , а все примеры кода будут написаны на TypeScript. React-testing-library — это фреймворк огромной библиотеки , которая для React по умолчанию использует в своей основе . Все что будет описано ниже, относится именно к этой библиотеке. Небольшая информация по синтаксису:
Библиотека построена на базе Jest, поэтому все методы с детальным описанием можно увидеть на официальной странице .
Как я уже говорил, имитирует поведение пользователя. В большинстве ситуаций вы будете использовать его. Вот полный список методов:
на официальную документацию.
Философия react-testing-library учит нас, что необходимо стремиться использовать userEvent как можно чаще для имитации пользовательского поведения. Но иногда возможностей userEvent не хватает. Тогда можно обратиться к fireEvent. По факту, userEvent — это оболочка над fireEvent, которая позволяет получить более высокий уровень абстракции. А вот — это имитация поведения DOM-элементов.
fireEvent принимает два аргумента: элемент DOM-дерева и объект события, которое с ним происходит. fireEvent содержит много методов — "keyPress", "focusOut", "drag" — полный список я смог найти только в их .
Для имитации роутинга нужно обернуть компонент в . Это специальный компонент, который помогает работать с роутингом вне браузера, в том числе для тестирования. Он хранит историю "URL". При этом не пишет в адресную строку и не читает из нее, так как в тестах никакой адресной строки нет.