原文链接:How to Test Custom React Hooks with React Testing Library,2023年5月10日,by Vishwas Gopinath
自定义的 React 钩子为开发人员提供了在多个组件之间提取和重用常见功能的能力。然而,测试这些钩子可能会很棘手,特别是对于新手来说。本文中,我们将探讨如何使用 React Testing Library 测试自定义的 React 钩子。
测试 React 组件
首先,让我们回顾一下如何测试一个基本的 React 组件。让我们以一个计数器组件为例,该组件显示一个计数和一个按钮,当点击时会增加计数。Counter
组件接受一个可选的 prop 称为 initialCount
,默认值为 0
(如果未提供)。以下是代码:
import { useState } from 'react'
type UseCounterProps = {
initialCount?: number
}
export const Counter = ({ initialCount = 0 }: CounterProps = {}) => {
const [count, setCount] = useState(initialCount)
return (
<div>
<h1>{count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
使用 React Testing Library 测试 Counter
组件的步骤如下:
- 使用 React Testing Library 中的
render
函数渲染组件 - 使用 React Testing Library 中的
screen
对象获取 DOM 元素。推荐使用 ByRole 来查询元素 - 使用
@testing-library/user-event
库模拟用户事件 - 对渲染输出进行断言
以下测试验证计数器组件的功能:
import { render, screen } from '@testing-library/react'
import { Counter } from './Counter'
import user from '@testing-library/user-event'
describe('Counter', () => {
test('renders a count of 0', () => {
render(<Counter />)
const countElement = screen.getByRole('heading')
expect(countElement).toHaveTextContent('0')
})
test('renders a count of 1', () => {
render(<Counter initialCount={1} />)
const countElement = screen.getByRole('heading')
expect(countElement).toHaveTextContent('1')
})
test('renders a count of 1 after clicking the increment button', async () => {
user.setup()
render(<Counter />)
const incrementButton = screen.getByRole('button', { name: 'Increment' })
await user.click(incrementButton)
const countElement = screen.getByRole('heading')
expect(countElement).toHaveTextContent('1')
})
})
第一个测试验证了计数器组件默认渲染为 0
。在第二个测试中,我们将初始计数值设为 1
,并测试渲染的计数值是否也是 1
。
最后,第三个测试检查了当点击增加按钮后,计数器组件是否能正确更新计数。
测试自定义 React 钩子
现在,让我们来看一个自定义钩子的例子,并且学习如何使用 React Testing Library 进行测试。我们将计数逻辑提取到了一个名为 useCounter
的自定义 React 钩子中。
这个钩子接受一个初始计数作为可选属性,并返回一个包含当前计数值和增加函数的对象。
以下是 useCounter
钩子的代码:
// useCounter.tsx
import { useState } from "react";
type UseCounterProps = {
initialCount?: number
}
export const useCounter = ({ initialCount = 0 }: CounterProps = {}) => {
const [count, setCount] = useState(initialCount);
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
return { count, increment };
};
使用这个自定义钩子,我们可以轻松地为 React 应用程序中的任何组件添加计数功能。现在,让我们来探索如何使用 React Testing Library 进行测试。
// useCounter.test.tsx
import { render } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
test("should render the initial count", () => {
render(useCounter) // Flags error
});
})
测试自定义 React 钩子遇到的问题
测试自定义的 React 钩子与测试组件是不同的。当你尝试通过将钩子传递给 render()
函数来测试它时,你会收到一个类型错误,指示该钩子不能被赋值给类型为 ReactElement<any, string | JSXElementConstructor<any>>
的参数。这是因为自定义钩子不返回任何 JSX(不像 React 组件)。
另一方面,如果你尝试在没有 render()
函数的情况下调用自定义钩子,你会在终端中看到一个控制台错误,指示钩子只能在函数组件内部调用。
// useCounter.test.tsx
import { render } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
test("should render the initial count", () => {
useCounter() // Flags error
});
})
测试自定义的 React 钩子确实会有些棘手。
使用 renderHook()
测试自定义 React 钩子
为了测试 React 中的自定义钩子,我们可以使用 React Testing Library 提供的 renderHook()
函数。这个函数允许我们渲染一个钩子并访问它的返回值。让我们看看如何更新之前的 useCounter()
的测试代码来使用renderHook()
:
// useCounter.test.tsx
import { renderHook } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
test("should render the initial count", () => {
const { result } = renderHook(useCounter);
expect(result.current.count).toBe(0);
});
})
在这个测试中,我们使用 renderHook()
来渲染我们的 useCounter()
钩子,并使用 result
对象获取其返回值。然后,使用 expect()
来验证初始计数为 0
。
请注意,该值保存在
result.current
中。将 result 视为一个 ref,存储最近一次提交的值。
带 options 选项的 renderHook()
我们还可以通过将选项对象作为第二个参数传递给 renderHook()
来测试钩子是否接受并渲染相同的初始计数:
test("should accept and render the same initial count", () => {
const { result } = renderHook(useCounter, {
initialProps: { initialCount: 10 },
});
expect(result.current.count).toBe(10);
});
在这个测试中,我们使用 initialProps
选项将一个带有 initialCount
属性设置为 10
的 options 对象传递给我们的 useCounter()
钩子,并使用 renderHook()
函数的 initialProps
选项。然后,我们使用 expect()
来验证计数是否等于 10
。
使用 act()
更新状态
对于我们的最后一个测试,让我们确保增量功能按预期工作。
为了测试 useCounter()
钩子的增加功能是否按预期工作,我们可以使用 renderHook()
渲染钩子并调用 result.current.increment()
。
然而,当我们运行测试时,它失败并显示错误消息:“Expected count to be 1 but received 0”。
test("should increment the count", () => {
const { result } = renderHook(useCounter);
result.current.increment();
expect(result.current.count).toBe(1);
});
错误消息还提供了一个关于出错原因的线索:”An update to TestComponent
inside a test was not wrapped in act(...)
.” 这表示导致状态更新的代码,也就是这里的 increment
函数,应该被包裹在 act(…) 中。
在 React Testing Library 中,act()
辅助函数确保组件的所有更新在进行断言之前都得到处理。
具体来说,在测试涉及状态更新的代码时,将该代码包装在 act()
函数中是必要的。这有助于准确模拟组件的行为,并确保测试反映的是真实运行的情况。
请注意,
act()
是由 React Testing Library 提供的一个辅助函数,用于包装会导致状态更新的代码。尽管该库通常会将所有这样的代码都包装在act()
中,但是当测试自定义钩子时直接调用导致状态更新的函数时,这种方式并不可行。在这种情况下,我们需要手动使用act()
来包装相关代码。
// useCounter.test.tsx
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
test("should increment the count", () => {
const { result } = renderHook(useCounter);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
通过使用 act()
将 increment()
函数包装起来,我们确保在执行断言之前应用了对状态的任何修改。这种方法还有助于避免由于异步更新而可能引发的潜在错误。
总结
当使用 React Testing Library 测试自定义钩子时,我们使用 renderHook()
函数来渲染我们的自定义钩子并验证它返回了预期的值。如果我们的自定义钩子接受 props
,我们可以使用 renderHook()
函数的 initialProps
选项传递它们。
此外,我们必须确保任何导致状态更新的代码都被 act() 工具函数包装起来以防止错误发生。有关使用 Jest 和React Testing Library 测试 React 应用程序的更多信息,请查看我的 React Testing 播放列表。