Полное руководство по тестированию хуков React
Опубликовано: 2022-03-11Хуки были представлены в React 16.8 в конце 2018 года. Это функции, которые подключаются к функциональному компоненту и позволяют нам использовать свойства состояния и компонента, такие как componentDidUpdate
, componentDidMount
и другие. Раньше это было невозможно.
Кроме того, хуки позволяют нам повторно использовать логику компонентов и состояний в разных компонентах. Раньше это было сложно сделать. Таким образом, крючки изменили правила игры.
В этой статье мы рассмотрим, как тестировать React Hooks. Мы выберем достаточно сложный хук и поработаем над его тестированием.
Мы ожидаем, что вы заядлый разработчик React, уже знакомый с React Hooks. Если вы хотите освежить свои знания, вам следует ознакомиться с нашим руководством, а вот ссылка на официальную документацию.
Крюк, который мы будем использовать для тестирования
В этой статье мы будем использовать хук, который я написал в своей предыдущей статье «Извлечение устаревших данных при повторной проверке с помощью хуков React». Хук называется useStaleRefresh
. Если вы не читали статью, не волнуйтесь, я повторю эту часть здесь.
Это хук, который мы будем тестировать:
import { useState, useEffect } from "react"; const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); setData(defaultValue); } // fetch new data fetch(url) .then((res) => res.json()) .then((newData) => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [url, defaultValue]); return [data, isLoading]; }
Как видите, useStaleRefresh
— это хук, который помогает извлекать данные из URL-адреса, возвращая кэшированную версию данных, если она существует. Он использует простое хранилище в памяти для хранения кеша.
Он также возвращает значение isLoading
, которое является истинным, если данные или кеш еще не доступны. Клиент может использовать его для отображения индикатора загрузки. Значение isLoading
устанавливается равным false, когда доступен кеш или свежий ответ.
На этом этапе я предлагаю вам потратить некоторое время на чтение приведенного выше хука, чтобы получить полное представление о том, что он делает.
В этой статье мы увидим, как мы можем протестировать этот хук, сначала не используя тестовые библиотеки (только React Test Utilities и Jest), а затем используя react-hooks-testing-library.
Мотивация использования тестовых библиотек, т.е. только Jest
для запуска тестов, заключается в том, чтобы продемонстрировать, как работает тестирование хука. Обладая этими знаниями, вы сможете отлаживать любые проблемы, которые могут возникнуть при использовании библиотеки, обеспечивающей абстракцию тестирования.
Определение тестовых случаев
Прежде чем мы начнем тестировать этот хук, давайте придумаем план того, что мы хотим протестировать. Поскольку мы знаем, что должен делать хук, вот мой восьмиэтапный план его тестирования:
- Когда хук монтируется с URL
url1
,isLoading
имеет значениеtrue
, а данные —defaultValue
. - После асинхронного запроса на выборку хук обновляется данными
data1
аisLoading
имеет значениеfalse
. - Когда URL-адрес изменяется на
url2
,isLoading
снова становится истинным, а данные —defaultValue
. - После асинхронного запроса на выборку хук обновляется новыми данными
data2
. - Затем мы меняем URL обратно на
url1
. Данныеdata1
мгновенно, так как они кэшируются.isLoading
является ложным. - После асинхронного запроса на выборку, когда получен свежий ответ, данные обновляются до
data3
. - Затем мы меняем URL обратно на
url2
. Данныеdata2
мгновенно, так как они кэшируются.isLoading
является ложным. - После асинхронного запроса на выборку, когда получен свежий ответ, данные обновляются до
data4
.
Упомянутый выше поток тестирования четко определяет траекторию функционирования хука. Поэтому, если мы сможем убедиться, что этот тест работает, мы в порядке.
Тестирование хуков без библиотеки
В этом разделе мы увидим, как тестировать хуки без использования каких-либо библиотек. Это даст нам глубокое понимание того, как тестировать React Hooks.
Чтобы начать этот тест, сначала мы хотели бы смоделировать fetch
. Это сделано для того, чтобы мы могли контролировать то, что возвращает API. Вот издевательская fetch
.
function fetchMock(url, suffix = "") { return new Promise((resolve) => setTimeout(() => { resolve({ json: () => Promise.resolve({ data: url + suffix, }), }); }, 200 + Math.random() * 300) ); }
Эта модифицированная fetch
предполагает, что тип ответа всегда JSON, и по умолчанию возвращает url
-адрес параметра в качестве значения data
. Он также добавляет к ответу случайную задержку от 200 до 500 мс.
Если мы хотим изменить ответ, мы просто устанавливаем suffix
второго аргумента в непустое строковое значение.
В этот момент вы можете спросить, почему задержка? Почему бы нам просто не вернуть ответ немедленно? Это потому, что мы хотим максимально воспроизвести реальный мир. Мы не сможем правильно протестировать хук, если вернем его мгновенно. Конечно, мы можем уменьшить задержку до 50-100 мс для более быстрых тестов, но давайте не будем об этом беспокоиться в этой статье.
Когда наш макет fetch готов, мы можем установить для него функцию fetch
. Для этого мы используем beforeAll
и afterAll
, потому что эта функция не имеет состояния, поэтому нам не нужно сбрасывать ее после отдельного теста.
// runs before any tests start running beforeAll(() => { jest.spyOn(global, "fetch").mockImplementation(fetchMock); }); // runs after all tests have finished afterAll(() => { global.fetch.mockClear(); });
Затем нам нужно смонтировать хук в компоненте. Почему? Потому что хуки — это просто функции сами по себе. Только при использовании в компонентах они могут реагировать на useState
, useEffect
и т. д.
Итак, нам нужно создать TestComponent
, который поможет нам установить наш хук.
// defaultValue is a global variable to avoid changing the object pointer on re-render // we can also deep compare `defaultValue` inside the hook's useEffect const defaultValue = { data: "" }; function TestComponent({ url }) { const [data, isLoading] = useStaleRefresh(url, defaultValue); if (isLoading) { return <div>loading</div>; } return <div>{data.data}</div>; }
Это простой компонент, который либо отображает данные, либо отображает текстовое приглашение «Загрузка» , если данные загружаются (извлекаются).
Когда у нас есть тестовый компонент, нам нужно смонтировать его в DOM. Мы используем beforeEach
и afterEach
для монтирования и размонтирования нашего компонента для каждого теста, потому что мы хотим начинать с новой DOM перед каждым тестом.
let container = null; beforeEach(() => { // set up a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; });
Обратите внимание, что container
должен быть глобальной переменной, поскольку мы хотим иметь к нему доступ для тестовых утверждений.
С этим набором давайте проведем наш первый тест, где мы визуализируем URL-адрес url1
, и, поскольку получение URL-адреса займет некоторое время (см. fetchMock
), сначала он должен отображать «загружаемый» текст.
it("useStaleRefresh hook runs correctly", () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); })
Запустите тест с помощью yarn test
, и он работает, как и ожидалось. Вот полный код на GitHub.
Теперь давайте проверим, когда этот loading
текст изменится на полученные данные ответа, url1
.
Как мы это делаем? Если вы посмотрите на fetchMock
, вы увидите, что мы ждем 200-500 миллисекунд. Что, если мы поместим sleep
в тест, который ждет 500 миллисекунд? Он покроет все возможные времена ожидания. Давайте попробуем это.
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } it("useStaleRefresh hook runs correctly", async () => { act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("loading"); await sleep(500); expect(container.textContent).toBe("url1"); });
Тест проходит, но мы тоже видим ошибку (код).
PASS src/useStaleRefresh.test.js ✓ useStaleRefresh hook runs correctly (519ms) console.error node_modules/react-dom/cjs/react-dom.development.js:88 Warning: An update to TestComponent inside a test was not wrapped in act(...).
Это связано с тем, что обновление состояния в useStaleRefresh
происходит вне функции act(). Чтобы убедиться, что обновления DOM обрабатываются своевременно, React рекомендует использовать act()
каждый раз, когда может произойти повторный рендеринг или обновление пользовательского интерфейса. Итак, нам нужно обернуть наш сон act
, так как это время, когда происходит обновление состояния. После этого ошибка исчезает.
import { act } from "react-dom/test-utils"; // ... await act(() => sleep(500));
Теперь запустите его снова (код на GitHub). Как и ожидалось, проходит без ошибок.
Давайте проверим следующую ситуацию, когда мы сначала изменим URL-адрес на url2
, затем проверим экран loading
, затем дождемся ответа на выборку и, наконец, проверим текст url2
. Поскольку теперь мы знаем, как правильно ожидать асинхронных изменений, это должно быть легко.

act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toContain("loading"); await act(() => sleep(500)); expect(container.textContent).toBe("url2");
Запустите этот тест, и он тоже пройдет. Теперь мы также можем протестировать случай, когда данные ответа изменяются и кеш вступает в игру.
Вы заметите, что у нас есть дополнительный suffix
аргумента в нашей функции fetchMock . Это для изменения данных ответа. Поэтому мы обновляем наш макет fetch, чтобы использовать suffix
.
global.fetch.mockImplementation((url) => fetchMock(url, "__"));
Теперь мы можем проверить случай, когда URL-адрес снова установлен на url1
. Сначала загружается url1
а затем url1__
. Мы можем сделать то же самое для url2
, и никаких сюрпризов быть не должно.
it("useStaleRefresh hook runs correctly", async () => { // ... // new response global.fetch.mockImplementation((url) => fetchMock(url, "__")); // set url to url1 again act(() => { render(<TestComponent url="url1" />, container); }); expect(container.textContent).toBe("url1"); await act(() => sleep(500)); expect(container.textContent).toBe("url1__"); // set url to url2 again act(() => { render(<TestComponent url="url2" />, container); }); expect(container.textContent).toBe("url2"); await act(() => sleep(500)); expect(container.textContent).toBe("url2__"); });
Весь этот тест дает нам уверенность, что хук действительно работает так, как ожидалось (код). Ура! Теперь давайте кратко рассмотрим оптимизацию с помощью вспомогательных методов.
Оптимизация тестирования с помощью вспомогательных методов
До сих пор мы видели, как полностью протестировать наш хук. Подход не идеальный, но работает. И все же, можем ли мы сделать лучше?
да. Обратите внимание, что мы ждем фиксированных 500 мс для завершения каждой выборки, но каждый запрос занимает от 200 до 500 мс. Итак, мы явно теряем здесь время. Мы можем справиться с этим лучше, просто ожидая времени, которое занимает каждый запрос.
Как мы это делаем? Простым методом является выполнение утверждения до тех пор, пока оно не пройдет или не будет достигнут тайм-аут. Давайте создадим функцию waitFor
, которая делает это.
async function waitFor(cb, timeout = 500) { const step = 10; let timeSpent = 0; let timedOut = false; while (true) { try { await sleep(step); timeSpent += step; cb(); break; } catch {} if (timeSpent >= timeout) { timedOut = true; break; } } if (timedOut) { throw new Error("timeout"); } }
Эта функция просто запускает обратный вызов (cb) внутри блока try...catch
каждые 10 мс, и если время timeout
, она выдает ошибку. Это позволяет нам выполнять утверждение до тех пор, пока оно не пройдет безопасным образом (т. е. без бесконечных циклов).
Мы можем использовать его в нашем тесте следующим образом: вместо ожидания в течение 500 мс и последующего подтверждения мы используем нашу функцию waitFor
.
// INSTEAD OF await act(() => sleep(500)); expect(container.textContent).toBe("url1"); // WE DO await act(() => waitFor(() => { expect(container.textContent).toBe("url1"); }) );
Сделайте это во всех таких утверждениях, и мы увидим значительную разницу в том, как быстро работает наш тест (код).
Теперь все это здорово, но, возможно, мы не хотим тестировать хук через пользовательский интерфейс. Возможно, мы хотим протестировать хук, используя его возвращаемые значения. Как мы это делаем?
Это будет несложно, потому что у нас уже есть доступ к возвращаемым значениям нашего хука. Они находятся только внутри компонента. Если мы сможем вывести эти переменные в глобальную область видимости, это сработает. Итак, давайте сделаем это.
Поскольку мы будем тестировать наш хук через его возвращаемое значение, а не визуализируемый DOM, мы можем удалить рендеринг HTML из нашего компонента и сделать его рендеринг null
. Мы также должны удалить деструктурирование в возврате хука, чтобы сделать его более общим. Таким образом, у нас есть этот обновленный тестовый компонент.
// global variable let result; function TestComponent({ url }) { result = useStaleRefresh(url, defaultValue); return null; }
Теперь возвращаемое значение хука хранится в глобальной переменной result
. Мы можем запросить его для наших утверждений.
// INSTEAD OF expect(container.textContent).toContain("loading"); // WE DO expect(result[1]).toBe(true); // INSTEAD OF expect(container.textContent).toBe("url1"); // WE DO expect(result[0].data).toBe("url1");
После того, как мы его везде поменяем, мы увидим, что наши тесты проходят (код).
На этом этапе мы получаем суть тестирования React Hooks. Есть несколько улучшений, которые мы все еще можем сделать, например:
- Перемещение переменной
result
в локальную область - Устранение необходимости создавать компонент для каждого хука, который мы хотим протестировать.
Мы можем сделать это, создав фабричную функцию, внутри которой находится тестовый компонент. Он также должен отображать хук в тестовом компоненте и давать нам доступ к переменной result
. Давайте посмотрим, как мы можем это сделать.
Во-первых, мы перемещаем TestComponent
и result
внутри функции. Нам также нужно будет передать Hook и аргументы Hook в качестве аргументов функции, чтобы их можно было использовать в нашем тестовом компоненте. Используя это, вот что у нас есть. Мы вызываем эту функцию renderHook
.
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } act(() => { render(<TestComponent hookArgs={args} />, container); }); return result; }
Причина, по которой у нас есть result
как объект, который хранит данные в result.current
, заключается в том, что мы хотим, чтобы возвращаемые значения обновлялись по мере выполнения теста. Возвращаемое значение нашего хука — это массив, поэтому он был бы скопирован по значению, если бы мы возвращали его напрямую. Сохраняя его в объекте, мы возвращаем ссылку на этот объект, чтобы возвращаемые значения можно было обновить, обновив result.current
.
Теперь, как нам обновить хук? Поскольку мы уже используем замыкание, давайте добавим еще одну функцию rerender
, которая может это сделать.
Окончательная функция renderHook
выглядит так:
function renderHook(hook, args) { let result = {}; function TestComponent({ hookArgs }) { result.current = hook(...hookArgs); return null; } function rerender(args) { act(() => { render(<TestComponent hookArgs={args} />, container); }); } rerender(args); return { result, rerender }; }
Теперь мы можем использовать его в нашем тесте. Вместо использования act
и render
мы делаем следующее:
const { rerender, result } = renderHook(useStaleRefresh, [ "url1", defaultValue, ]);
Затем мы можем утверждать с помощью result.current
и обновлять хук с помощью rerender
. Вот простой пример:
rerender(["url2", defaultValue]); expect(result.current[1]).toBe(true); // check isLoading is true
Как только вы измените его во всех местах, вы увидите, что он работает без проблем (код).
Великолепно! Теперь у нас есть более чистая абстракция для тестирования хуков. Мы все еще можем сделать лучше — например, defaultValue
необходимо передавать каждый раз для rerender
даже если оно не меняется. Мы можем это исправить.
Но давайте не будем ходить вокруг да около, поскольку у нас уже есть библиотека, которая значительно улучшает этот опыт.
Войдите в библиотеку тестирования-реакций-хуков.
Тестирование с использованием React-hooks-testing-library
Библиотека React-hooks-testing-library делает все, о чем мы говорили ранее, и даже кое-что еще. Например, он обрабатывает монтирование и размонтирование контейнера, поэтому вам не нужно делать это в тестовом файле. Это позволяет нам сосредоточиться на тестировании наших хуков, не отвлекаясь.
Он поставляется с функцией renderHook
, которая возвращает rerender
и result
. Он также возвращает wait
, который похож на waitFor
, поэтому вам не нужно реализовывать его самостоятельно.
Вот как мы визуализируем хук в библиотеке React-hooks-testing-library. Обратите внимание, что хук передается в виде обратного вызова. Этот обратный вызов запускается каждый раз при повторном рендеринге тестового компонента.
const { result, wait, rerender } = renderHook( ({ url }) => useStaleRefresh(url, defaultValue), { initialProps: { url: "url1", }, } );
Затем мы можем проверить, привел ли первый рендеринг к isLoading
как true и возврату значения как defaultValue
, выполнив это. Точно так же, как мы реализовали выше.
expect(result.current[0]).toEqual(defaultValue); expect(result.current[1]).toBe(true);
Чтобы протестировать асинхронные обновления, мы можем использовать метод wait
, который вернул renderHook
. Он поставляется в обертке с act()
, поэтому нам не нужно оборачивать его в act()
.
await wait(() => { expect(result.current[0].data).toEqual("url1"); }); expect(result.current[1]).toBe(false);
Затем мы можем использовать rerender
, чтобы обновить его новыми реквизитами. Обратите внимание, что нам не нужно передавать здесь defaultValue
.
rerender({ url: "url2" });
Наконец, остальная часть теста будет выполняться аналогично (код).
Подведение итогов
Моя цель состояла в том, чтобы показать вам, как тестировать React Hooks, на примере асинхронного хука. Я надеюсь, что это поможет вам уверенно браться за тестирование любого вида хуков, так как тот же подход должен применяться к большинству из них.
Я бы порекомендовал вам использовать библиотеку React-hooks-testing-library, поскольку она завершена, и до сих пор у меня не было серьезных проблем с ней. Если вы столкнулись с проблемой, теперь вы знаете, как к ней подойти, используя хитрости тестовых хуков, описанных в этой статье.