在这篇文章中,我们将探讨如何使用 Vitest 和 React 测试库来帮助对 React hooks 进行单元测试,从而使它们易于维护和扩展。
设置 Vitest 和 JSDOM
Vitest由 Vite 提供支持,声称是 Vite 项目的“极速单元测试框架”。Vitest 提供与 Jest 类似的功能和语法,开箱即用地支持 TypeScript/JSX,同时观看 (HRM) 和运行测试速度更快。
尽管最初是为 Vite 支持的项目构建的,但我们也可以在非 Vite 项目(例如 Webpack)中通过适当的配置使用 Vitest。
要在 React 项目中设置 Vitest,我们可以使用以下命令将 Vitest 安装为开发依赖项
yarn -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
inpackage.json
文件中以运行单元测试,如下所示:
"scripts": {
"test:unit": "vitest --root src/"
}
在上面的代码中,我们将测试的根目录设置为src/
文件夹。或者,我们可以将其放在田地vite.config.js
下面test.root
。两种方法的工作原理相同。
接下来,让我们添加一些测试。
使用 React 测试库测试常规钩子
Vitest 支持测试任何 JavaScript 和 TypeScript 代码。但是,为了测试 React 组件特定的功能(例如 React hooks),我们仍然需要为所需的 hook 创建一个包装器并模拟 hook 的执行。
为此,我们可以安装并使用React 测试库中的 Render hooks:
yarn add -D @testing-library/react-hooks
完成后,我们可以使用renderHook
此包中的方法来呈现所需的钩子。renderHook
将返回一个对象实例,其中包含result
属性和其他有用的实例方法,例如unmount
和waitFor
。然后我们可以从属性访问钩子的返回值result.current
。
例如,让我们看一下一个useSearch
钩子,它接收初始项目数组并返回反应式 searchTerm 的对象、过滤后的项目列表以及更新搜索词的方法。其实现如下:
//hooks/useSearch.ts
import { useState, useMemo } from "react";
export const useSearch = (items: any[]) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredItems = useMemo(
() => items.filter(
movie => movie.title.toLowerCase().includes(searchTerm.toLowerCase())
)
, [items, searchTerm]);
return {
searchTerm,
setSearchTerm,
filteredItems
};
}
我们可以编写一个测试来检查钩子 forsearchTerm
和 for的默认返回值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
from 的方法@testing-library/react-hooks
不会等待异步调用完成。由于我们不知道提取何时会解决,因此我们无法movies
在完成后断言该值。
为了解决这个问题,我们可以使用waitFor
from 的方法@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
接受回调并返回一个 Promise,该 Promise 在回调成功执行时解析。在上面的代码中,我们等待该movies
值等于预期值。或者,我们可以传递一个对象作为第二个参数来waitFor
配置轮询的超时和间隔。例如,我们可以将超时设置为 1000ms,如下所示:
await waitFor(() => {
expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
}, {
timeout: 1000
});
这样,如果movies
1000 毫秒后该值不等于预期值,则测试将失败。
使用spyOn和waitFor监视和测试外部API调用
在之前的测试中useMovies
,我们使用API获取外部数据fetch
,这对于单元测试来说并不理想。相反,我们应该使用该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()
方法清除所有mock的信息,例如调用次数和mock的结果。当断言模拟的调用或模拟具有不同测试的不同返回值的同一函数时,此方法非常方便。我们通常使用mockClear()
inbeforeEach
或afterEach
方法来确保我们的测试完全隔离。
就是这样。您现在可以继续有效地测试您的自定义挂钩。
笔记
不幸的是,目前@testing-library/react-hooks
不适用于 React 18。该包正在迁移到 React 测试库的官方包 ( @testing-library/react
) 中。某些功能,例如waitForNextUpdate
将不再可用。
概括
在本文中,我们将尝试如何使用 React Hooks 测试库和 Vitest 包来测试自定义钩子。我们还学习了如何使用该方法测试具有异步逻辑的钩子,以及如何使用Vitest 的方法waitFor
监视外部 API 调用。vi.spyOn
,所以 在下一篇文章中我将介绍——如何使用 React 测试库和 Vitest 包测试 React 组件