Тестирование React-компонентов: часть вторая, практика
Выпускник курса по JavaScript в Elbrus Bootcamp Даниил Замешаев разбирается, как тестировать компоненты, в которых используется роутинг с помощью React-router-dom и что делать, если в компоненте есть асинхронный код.
Во второй части статьи на примерах рассмотрим процесс тестирования React-компонентов. Статья предназначена для студентов, которые уже закончили Elbrus Bootcamp, ищут или уже нашли первую работу и столкнулись с необходимостью написать тесты.
Работа с DOM-деревом: поиск элемента
В первой статье описаны основным концепции — теперь углубимся в синтаксис библиотеки react-testing-library и разберем ее особенности и тонкости.
Поисковые методы по DOM-дереву, которые предоставляет объект screen, делятся на три категории:
- getBy — поиск элемента на странице
2. queryBy — поиск элемента, которого нет на странице
3. findBy — поиск элемента на странице, который зависит от асинхронного кода.
Если getBy не вызывает вопросов и используется чаще всего, то две другие категории требуют отдельного разбора.
Начнем с queryBy: в примере из предыдущей статьи есть валидация операции отправки денег. Если валидация не прошла, пользователь получает следующую ошибку:
{error.status && (
<div className="error">
{error.message}
</div>
)}
Поскольку ошибка выдается только при проблемах с валидацией, пользователь не должен видеть ее сразу. Проверить, что этого не происходит, можно с помощью queryBy:
test('Error element: not to be in the component by default', async ()=> {
render(<Send />)
expect(screen.queryByText('Error')).not.toBeInTheDocument();
})
Важно: для любого утверждения после expect не существует обратного утверждения (например, NotToBeInTheDocument). Все отрицания выполняются с помощью конструкции .not перед выражением. Если вы попробуете выполнить поиск, используя getBy, то получите ошибку в синтаксисе теста — он не найдет элемент.
Перейдем к третьей категории, findBy. Она позволяет загрузить имя юзера асинхронно:
const getUser = () => Promise.resolve({ id: '1', name: 'John Doe' })
Отрисовка элемента происходит после получения данных о юзере:
{user && <div>Hello {user.name}</div>}
Поэтому используем findBy:
test('User element: exist after fetch data', async function () {
render(<Send />)
expect(await screen.findByText(/Hello/)).toBeInTheDocument()
});
Стоит уточнить, что если нам необходимо дождаться выполнения асинхронной функции, то и callback-функция, передаваемая в тест, должна быть асинхронной.
Другие методы поиска можно найти в таблице:
Не будем разбираться каждый из них, а остановимся только на двух:
- Поиск по роли (getByRole, queryByRole, findByRole). Позволяет найти элемент по его логической роли в документе. Например:
‘combobox’ — тег <select />
‘button’ — тег <button /> или <input type=«submit» />
В примере выше getByRole используется для поиска кнопки отправки:
userEvent.click(screen.getByRole('button'));
- Поиск по testId. Любому компоненту в коде можно указать data-атрибут testId и использовать его значение при поиске элемента:
<div data-testid="custom-element" />
У всех методов поиска есть варианты поиска элементов на странице, соответствующих условию:
- getAllByText
- queryAllByPlaceholderText
- findAllByTestId
- и т.д. для всех поисковых методов
Логические выражения
Отдельно обратим внимание на логические выражения и правила их использования. В данном случае их основная задача — проверить выполнение теста:
expect(<реальное состояние>).toBe(<ожидаемое состояние>);
Ниже приведем три примера использования разных методов функции 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» будет недоступно для пользовательского ввода.
Библиотека построена на базе Jest, поэтому обо всех методах можно подробно прочитать на официальной странице Jest.
Лучше всего использовать логические выражения, которые максимально подходят под ситуацию. С одной стороны это сделает код понятным другим программистам, которые будут его читать, а с другой — сделает ошибки в логах более понятными. Это поможет разобраться, что именно пошло не так, если тест провалится.
userEvent и fireEvent
В первой части статьи говорилось, что userEvent имитирует поведение пользователя. В большинстве ситуаций этого метода достаточно, но бывают и исключения. Полный список методов можно найти в официальной документации библиотеки..
В документации к react-testing-library говорится, что стоит использовать userEvent как можно чаще для имитации пользовательского поведения. Но иногда возможностей userEvent не хватает. В этом случае можно обратиться к fireEvent.
По сути, userEvent — это оболочка над fireEvent, которая позволяет получить более высокий уровень абстракции. А сам fireEvent — это имитация поведения DOM-элементов.
Возьмем метод type у userEvent и сравним с тем же событием в fireEvent:
- userEvent:
userEvent.type(screen.getByLabelText<HTMLInputElement>('Amount'), '10')
- fireEvent:
fireEvent.change(screen.getByLabelText<HTMLInputElement>('Amount'), {target: { value: '10' }});
fireEvent принимает на вход два аргумента: элемент DOM-дерева и объект события, которое с ним происходит. fireEvent содержит много методов, полный список которых можно найти в их типах.
Redux
Для тестирования компонента, который берет данные из Redux-store, создадим настоящий store, внутрь которого предадим заранее написанные данные.
Функция renderWithRedux принимает компонент, который нужно отрисовать, и данные, с которыми этот элемент будет отрисован. Внутри себя она создает store и оборачивает компонент тегом <Provider/>, куда и передает созданный store. Обратите внимание: функция возвращает не только рендер компонента, но и сам store.
import { createStore } from "redux";
import { Provider } from "react-redux";
import { reducer, WalletItem } from "../../redux/store";
const renderWithRedux = (
component: JSX.Element,
{ initialState,
store = createStore(reducer, initialState)
}: {initialState?: WalletItem[]; store?: any } = {}
) => {
return {
...render((
<Provider store={store}>
{component}
</Provider>,
store
}
}
Разберем на примере реального теста:
test('From element: default value is the first wallet', function () {
const { store } = renderWithRedux(<Send />, { initialState: MOCK_WALLET_LIST})
const state = store.getState();
expect(screen.getByLabelText<HTMLSelectElement>('From').value).toBe(state[0].id.toString());
});
Тест проверяет, что значение поля ввода с лейблом «From» по умолчанию будет равно «id» первого кошелька.
В функцию renderWithRedux передается тестируемый компонент и данные, а возвращает функция store. Это обычный store Redux, из него мы получаем state и можем отслеживать, как он меняется в результате различных действий. Например:
test('SendTransaction: balances were changed after sending funds', () => {
const { store } = renderWithRedux(<Send />, { initialState: MOCK_WALLET_LIST})
const state = store.getState();
userEvent.type(screen.getByLabelText<HTMLInputElement>('Amount'), '5')
userEvent.click(screen.getByRole('button'));
expect(state[0].balance).toBe(4.95);
expect(state[1].balance).toBe(12);
})
Тест выше проверяет, что после того, как пользователь ввел в поле Amount строку «5» и нажал на кнопку, выполняется отправка денег. В результате баланс одного кошелька составит «4.95», а другого — «12».
Важно помнить, что все тесты работают с одним стором. Если стор в первом тесте изменен (например, для проверки отправили деньги с одного кошелька на другой), то во всех последующих тестах баланс кошельков будет отличаться от исходного.
Функция renderWithRedux может быть написана иначе: в данном случае реализация не так важна. Главное, чтобы она оборачивала компонент провайдером со store. Реализация выше написана не мной и применяется повсеместно — это своего рода стандарт.
Router
Роутинг при тестировании придется имитировать в нескольких случаях: для проверки адреса, куда переходит пользователь, и при использовании в компоненте navigate:
const navigate = useNavigate();
…
navigate('/success');
Для имитации роутинга обернем компонент в <MemoryRouter/>. Это специальный компонент, который помогает работать с роутингом вне браузера, в том числе для тестирования. Он хранит историю URL, не пишет в адресную строку и не читает из нее — это удобно, учитывая, что в тестах адресной строки нет.
const renderWithRouter = (
component: JSX.Element ) => (
render((
<MemoryRouter>
{component}
</MemoryRouter>))
)
В примере приложения есть три роута:
- "/" — страница "Welcome";
- "/send" — страница "Send";
- "/success" — страница "Success";
Для тестирования этого примера нужно выполнить рендер компонентов не только с роутингом, но и с Redux. Так выглядит функция renderWithReduxAndRouter:
const renderWithReduxAndRouter = (
component: JSX.Element,
{ initialState,
store = createStore(reducer, initialState)
}: {initialState?: WalletItem[]; store?: any } = {}
) => {
return {
...render((
<MemoryRouter>
<Provider store={store}>
{component}
</Provider>
</MemoryRouter>)),
store
}
}
Компонент, обеспечивающий роутинг, в примере называется <NavBar/> и выглядит следующим образом:
import React from 'react';
import {Link, Route, Routes} from "react-router-dom";
import Send from "./send";
import Success from "./success";
import Welcome from "./welcome";
function NavBar() {
return (
<div>
<nav className="navbar">
<Link className="navbarLink" to='/'>Welcome</Link>
<Link className="navbarLink" to='/send'>Send</Link>
</nav>
<Routes>
<Route path={'/'} element={<Welcome/>}/>
<Route path={'/send'} element={<Send/>}/>
<Route path='/success' element={<Success/>}/>
</Routes>
</div>
);
}
export default NavBar;
Тесты, которые проверяют все три роута, выглядят так:
test('First element is Welcome page', () => {
renderWithReduxAndRouter(<NavBar/>, { initialState: MOCK_WALLET_LIST })
expect(screen.getByText('Welcome!')).toBeInTheDocument();
})
Тест проверяет, что первая страница, которую видит пользователь — это страница приветствия.
test('After clicking the "Send" link, the "Send" page opens.', () => {
renderWithReduxAndRouter(<NavBar/>, { initialState: MOCK_WALLET_LIST })
userEvent.click(screen.getByText('Send'));
expect(screen.getByRole('button')).toBeInTheDocument()
})
Тест проверяет, что после нажатия на ссылку Send пользователь переходит на страницу отправки средств.
test('After clicking the "Confirm" button, the "Success" page opens.', () => {
const { store } = renderWithReduxAndRouter(<NavBar/>, { initialState: MOCK_WALLET_LIST })
const state = store.getState();
userEvent.click(screen.getByText('Send'));
userEvent.selectOptions(screen.getByLabelText<HTMLSelectElement>('From'), state[3].id.toString())
userEvent.selectOptions(screen.getByLabelText<HTMLSelectElement>('To'), state[4].id.toString())
userEvent.type(screen.getByLabelText('Amount'), '1');
userEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('success')).toBeInTheDocument()
})
Тест проверяет, что после отправки средств пользователь переходит на страницу с сообщением об успешности перевода. На этом примере видно, как перед отправкой были выбраны кошельки 4 и 5. Дело в том, что тест, который изменяет балансы кошельков, уже существует, а store единый для всех тестов. Значит, нужно использовать балансы кошельков, которые до этого не участвовали в тестах, либо учитывать эти изменения в других тестах.
Заключение
Писать тесты — не то же самое, что писать код. Главное отличие состоит в необходимости имитировать различные сущности: роутер, пользовательское поведение, данные, библиотеки. Именно в этой области и возникают основные сложности при написании тестов.
В спорах о необходимости тестирования поломано немало копий, но если вы дочитали этот гайд, значит, на этот вопрос для себя вы уже ответили.
Посмотреть репозиторий с приложением, написанным для этой статьи, можно здесь. Он содержит 27 различных тестов, далеко не все из которых вошли в материал.