В этом посте рассказывается, как использовать Vitest и библиотеку тестирования React, чтобы помочь модульному тестированию хуков React таким образом, чтобы их было легко поддерживать и расширять.
Настройка Vitest и JSDOM
Работающий на Vite, Vitest претендует на звание молниеносно быстрой среды модульного тестирования для проектов Vite. Vitest предлагает функции и синтаксис, аналогичные Jest, с поддержкой TypeScript/JSX из коробки, но при этом быстрее в просмотре (HRM) и выполнении ваших тестов.
Несмотря на то, что изначально он был создан для проектов на базе Vite, мы также можем использовать Vitest с правильной конфигурацией в проектах, отличных от Vite, таких как Webpack.
Чтобы настроить Vitest в проекте React, мы можем установить Vitest как зависимость от разработчиков, используя следующую команду.
yarn -D vitest #OR npm i -D vitest
Мы также устанавливаем jsdom
(или любую реализацию стандартов DOM) в качестве зависимости от разработчиков:
yarn add -D jsdom
Затем в vitest.config.js
(или vite.config.js
для проектов Vite) мы добавляем в конфигурацию следующий объект test
с jsdom
в качестве нашей среды DOM для тестирования:
//... export default defineConfig({ test: { environment: 'jsdom', }, })
Мы также можем установить global: true
, чтобы нам не приходилось явно импортировать каждый метод, такой как expect
, describe
, it
или vi
, из пакета vitest
в каждом тестовом файле.
Закончив с vitest.config.js
, мы добавляем новую команду в файл scripts
в package.json
для запуска модульных тестов следующим образом:
"scripts": { "test:unit": "vitest --root src/" }
В приведенном выше коде мы устанавливаем корень наших тестов как папку src/
. В качестве альтернативы мы можем поместить его в vite.config.js
под полем test.root
. Оба подхода работают одинаково.
Далее добавим несколько тестов.
Тестирование обычного хука с помощью React Testing Library
Vitest поддерживает тестирование любого кода JavaScript и TypeScript. Однако для тестирования специфичных для компонента функций React, таких как хуки React, нам все равно нужно создать оболочку для нужного хука и смоделировать выполнение хука.
Для этого мы можем установить и использовать Render hooks from React Testing Library:
yarn add -D @testing-library/react-hooks
После этого мы можем использовать метод renderHook
из этого пакета для рендеринга нужного хука. renderHook
вернет экземпляр объекта, содержащий свойство result
и другие полезные методы экземпляра, такие как unmount
и waitFor
. Затем мы можем получить доступ к возвращаемому хуком значению из свойства result.current
.
Например, давайте посмотрим на хук useSearch
, который получает начальный массив элементов и возвращает объект реактивного searchTerm, отфильтрованный список элементов и метод для обновления условия поиска. Его реализация выглядит следующим образом:
//hooks/useSearch.ts import { useState, useEffect } from "react"; export const useSearch = (items: any[]) => { const [searchTerm, setSearchTerm] = useState(''); const [filteredItems, setFilteredItems] = useState(items); useEffect(() => { setFilteredItems( items.filter( movie => movie.title.toLowerCase() .includes(searchTerm.toLowerCase()) ) ) }, [items, searchTerm]); return { searchTerm, setSearchTerm, filteredItems }; }
Мы можем написать тест для проверки значений, возвращаемых хуком по умолчанию для searchTerm
и filterItems
, как показано ниже:
import { expect, it, describe } from "vitest"; import { renderHook } from '@testing-library/react-hooks' import { useSearch } from "./useSearch" describe('useSearch', () => { it('should return a default search term and original items', () => { const items = [{ title: 'Star Wars' }]; const { result } = renderHook(() => useSearch(items)); expect(result.current.searchTerm).toBe(''); expect(result.current.filteredItems).toEqual(items); }); });
Чтобы проверить, работает ли хук при обновлении searchTerm
, мы можем использовать метод act()
для имитации выполнения setSearchTerm
, как показано в приведенном ниже тестовом примере:
import { /**... */ act } from "vitest"; //... it('should return a filtered list of items', () => { const items = [ { title: 'Star Wars' }, { title: 'Starship Troopers' } ]; const { result } = renderHook(() => useSearch(items)); act(() => { result.current.setSearchTerm('Wars'); }); expect(result.current.searchTerm).toBe('Wars'); expect(result.current.filteredItems).toEqual([{ title: 'Star Wars' }]); }); //...
Обратите внимание, здесь вы не можете деструктурировать реактивные свойства экземпляра result.current
, иначе они потеряют свою реактивность. Например, приведенный ниже код не будет работать:
const { searchTerm } = result.current; act(() => { result.current.setSearchTerm('Wars'); }); expect(searchTerm).toBe('Wars'); // This won't work
Далее мы можем перейти к тестированию более сложного хука useMovies
, который содержит асинхронную логику.
Хук для тестирования с асинхронной логикой
Давайте посмотрим на приведенный ниже пример реализации хука useMovies
:
export const useMovies = ():{ movies: Movie[], isLoading: boolean, error: any } => { const [movies, setMovies] = useState([]); const fetchMovies = async () => { try { setIsLoading(true); const response = await fetch("https://swapi.dev/api/films"); if (!response.ok) { throw new Error("Failed to fetch movies"); } const data = await response.json(); setMovies(data.results); } catch (err) { //do something } finally { //do something } }; useEffect(() => { fetchMovies(); }, []); return { movies } }
В приведенном выше коде хук запускает асинхронный вызов fetchMovies
при первом рендеринге, используя хук синхронного эффекта useEffect
. Эта реализация приводит к проблеме, когда мы пытаемся протестировать ловушку, так как метод renderHook
из @testing-library/react-hooks
не ожидает завершения асинхронного вызова. Поскольку мы не знаем, когда разрешится выборка, мы не сможем подтвердить значение movies
после ее завершения.
Чтобы решить эту проблему, мы можем использовать метод waitFor
из @testing-library/react-hooks
, как в следующем коде:
/**useMovies.test.ts */ describe('useMovies', () => { //... it('should fetch movies', async () => { const { result, waitFor } = renderHook(() => useMovies()); await waitFor(() => { expect(result.current.movies).toEqual([{ title: 'Star Wars' }]); }); }); //... });
waitFor
принимает обратный вызов и возвращает обещание, которое разрешается при успешном выполнении обратного вызова. В приведенном выше коде мы ждем, пока значение movies
сравняется с ожидаемым значением. При желании мы можем передать объект в качестве второго аргумента waitFor
, чтобы настроить время ожидания и интервал опроса. Например, мы можем установить тайм-аут на 1000 мс, как показано ниже:
await waitFor(() => { expect(result.current.movies).toEqual([{ title: 'Star Wars' }]); }, { timeout: 1000 });
При этом, если значение movies
не равно ожидаемому значению через 1000 мс, тест завершится неудачей.
Отслеживание и тестирование внешнего вызова API с помощью spyOn и waitFor
В предыдущем тесте для useMovies
мы извлекали внешние данные с помощью fetch
API, который не идеален для модульного тестирования. Вместо этого мы должны использовать метод vi.spyOn
(с vi
в качестве экземпляра Vitest), чтобы шпионить за методом global.fetch
и имитировать его реализацию, чтобы вернуть поддельный ответ, как в следующем коде:
import { /**... */ vi, beforeAll } from "vitest"; describe('useMovies', () => { //Spy on the global fetch function const fetchSpy = vi.spyOn(global, 'fetch'); //Run before all the tests beforeAll(() => { //Mock the return value of the global fetch function const mockResolveValue = { ok: true, json: () => new Promise((resolve) => resolve({ results: [{ title: 'Star Wars' }] })) }; fetchSpy.mockReturnValue(mockResolveValue as any); }); it('should fetch movies', async () => { /**... */ } });
В приведенном выше коде мы имитируем возвращаемое значение fetchSpy
, используя его метод mockReturnValue()
с созданным нами значением. С помощью этой реализации мы можем запустить наш тест, не вызывая реальный вызов API, что снижает вероятность сбоя теста из-за внешних факторов.
И поскольку мы издеваемся над возвращаемым значением метода fetch
, нам нужно восстановить его исходную реализацию после завершения тестов, используя метод mockRestore
из Vitest, как в следующем коде:
import { /**... */ vi, beforeAll, afterAll } from "vitest"; describe('useMovies', () => { const fetchSpy = vi.spyOn(global, 'fetch'); /**... */ //Run after all the tests afterAll(() => { fetchSpy.mockRestore(); }); });
Кроме того, мы также можем использовать метод mockClear()
для очистки всей информации о макете, такой как количество вызовов и результаты макета. Этот метод удобен при утверждении вызовов мока или имитации одной и той же функции с разными возвращаемыми значениями для разных тестов. Обычно мы используем mockClear()
в методе beforeEach
или afterEach
, чтобы обеспечить полную изоляцию нашего теста.
Вот и все. Теперь вы можете пойти дальше и поэкспериментировать, эффективно тестируя свои пользовательские хуки.
Примечание К сожалению, в настоящее время @testing-library/react-hooks
не работает с React 18. Пакет находится в процессе переноса в официальный пакет библиотеки тестирования React (@testing-library/react
). Некоторые функции, такие как waitForNextUpdate
, больше не будут доступны.
Краткое содержание
В этой статье мы поэкспериментируем с тестированием пользовательских хуков с помощью библиотеки тестирования React Hooks и пакета Vitest. Мы также узнали, как тестировать перехватчики с асинхронной логикой с помощью метода waitFor
и как отслеживать внешние вызовы API с помощью метода vi.spyOn
от Vitest.
Что дальше? Как только наши хуки будут хорошо протестированы, мы можем перейти к следующему уровню тестирования — тестированию компонентов React с использованием библиотеки тестирования React и пакета Vitest. Так что следите за обновлениями!
👉 Если хотите иногда быть со мной в курсе, подпишитесь на меня в Твиттере | Фейсбук.
👉 Узнайте больше о Vue из моей новой книги Learning Vue. Ранний выпуск уже доступен!
Понравился этот пост или нашел его полезным? Поделись 👇🏼 😉
Первоначально опубликовано на https://mayashavin.com.