В этом посте рассказывается, как использовать 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.spyOnvi в качестве экземпляра 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.