如何使用 React Testing Library 测试自定义 React 钩子?

1,084 阅读6分钟

原文链接: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 组件的步骤如下:

  1. 使用 React Testing Library 中的 render 函数渲染组件
  2. 使用 React Testing Library 中的 screen 对象获取 DOM 元素。推荐使用 ByRole 来查询元素
  3. 使用 @testing-library/user-event 库模拟用户事件
  4. 对渲染输出进行断言

以下测试验证计数器组件的功能:

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 播放列表