实践中的组件测试

组件测试是开始演示实践测试代码的好地方。组件测试比简单的单元测试更充实,比端到端测试更简单,并演示了与 DOM 的交互。更哲学地说,React 的使用使 Web 开发人员更容易将网站或 Web 应用程序视为由组件构成。

因此,测试单个组件(无论它们有多复杂)是开始考虑测试新的或现有应用程序的好方法。

此页面介绍了如何测试具有复杂外部依赖关系的小组件。测试不与任何其他代码交互的组件很容易,例如,通过单击按钮并确认数字增加。实际上,很少有代码像这样,并且测试没有交互的代码的价值可能有限。

被测组件

我们使用 Vitest 及其 JSDOM 环境来测试 React 组件。这使我们可以在命令行上使用 Node 快速运行测试,同时模拟浏览器。

A list of names with a
    Choose button next to each name.
一个小的 React 组件,显示来自网络的用户列表。

这个名为 UserList 的 React 组件从网络获取用户列表,并允许您选择其中一个。用户列表是使用 useEffect 内的 fetch 获取的,选择处理程序由 Context 传入。这是它的代码

import React, { useEffect, useState, useContext } from 'react';
import { UserContext } from './UserContext.tsx';
import { UserRow } from './UserRow.tsx';

export function UserList({ count = 4 }: { count?: number }) {
  const [users, setUsers] = useState<any[]>([]);
  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users?_limit=' + count)
      .then((response) => response.json())
      .then((json) => setUsers(json));
  }, [count]);

  const c = useContext(UserContext);
  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((u) => (
          <li key={u.id}>
            <button onClick={() => c.userChosen(u.id)}>Choose</button>{' '}
            <UserRow u={u} />
          </li>
        ))}
      </ul>
    </div>
  );
}

此示例未演示 React 最佳实践(例如,它在 useEffect 内使用了 fetch),但您的代码库可能包含许多类似的情况。更重要的是,这些情况乍一看可能很难测试。本课程的未来章节将详细讨论编写可测试的代码。

以下是我们在此示例中测试的内容:

  • 检查是否创建了一些正确的 DOM 以响应来自网络的数据。
  • 确认单击用户会触发回调。

每个组件都不同。是什么让测试这个组件变得有趣?

  • 它使用全局 fetch 从网络请求真实数据,这在测试中可能不稳定或缓慢。
  • 它导入另一个类 UserRow,我们可能不想隐式测试它。
  • 它使用 Context,它不是被测代码的特定部分,通常由父组件提供。

编写快速测试以开始

我们可以快速测试有关此组件的一些非常基本的内容。需要明确的是,此示例不是很有用。但它有助于在名为 UserList.test.tsx 的对等文件中设置样板文件(请记住,默认情况下,像 Vitest 这样的测试运行程序会运行以 .test.js 或类似文件结尾的文件,包括 .tsx

import { vi, test, assert, afterAll } from 'vitest';
import { render } from '@testing-library/react';
import { UserList } from './UserList.tsx';
import React, { ContextType } from 'react';

test('render', async () => {
  const c = render(<UserList />);

  const headingNode = await c.findAllByText(/Users);
  assert.isNotNull(headingNode);
});

此测试断言,当组件呈现时,它包含文本“Users”。即使组件具有向网络发送 fetch 的副作用,它也能正常工作。fetch 在测试结束时仍在进行中,没有设置终结点。我们无法确认在测试结束时是否显示任何用户信息,至少在不等待超时的情况下无法确认。

模拟 fetch()

模拟是指用您在测试中控制的东西替换真实函数或类。这在几乎所有类型的测试中都很常见,最简单的单元测试除外。这在 断言和其他基元 中有更多介绍。

您可以为您的测试模拟 fetch(),使其快速完成并返回您期望的数据,而不是“真实世界”或未知数据。fetch 是一个全局变量,这意味着我们不必 importrequire 它到我们的代码中。

在 vitest 中,您可以通过调用 vi.stubGlobalvi.fn() 返回的特殊对象来模拟全局变量——这构建了一个我们可以稍后修改的模拟。这些方法在本课程的后续部分中会更详细地介绍,但您可以在以下代码中看到它们在实践中的应用

test('render', async () => {
  const fetchMock = vi.fn();
  fetchMock.mockReturnValue(
    Promise.resolve({
      json: () => Promise.resolve([{ name: 'Sam', id: 'sam' }]),
    }),
  );
  vi.stubGlobal('fetch', fetchMock);

  const c = render(<UserList />);

  const headingNode = await c.queryByText(/Users);
  assert.isNotNull(headingNode);

  await waitFor(async () => {
    const samNode = await c.queryByText(/Sam);
    assert.isNotNull(samNode);
  });
});

afterAll(() => {
  vi.unstubAllGlobals();
});

此代码添加了一个模拟,描述了网络获取 Response 的虚假版本,然后等待它出现。如果文本未出现——您可以通过将 queryByText 中的查询更改为新名称来检查这一点——测试将失败。

此示例使用了 Vitest 的内置模拟助手,但其他测试框架也有类似的模拟方法。Vitest 的独特之处在于,您必须在所有测试后调用 vi.unstubAllGlobals(),或设置 等效的全局选项。如果不“撤消”我们的工作,fetch 模拟可能会影响其他测试,并且每个请求都将使用我们奇怪的 JSON 堆进行响应。

模拟导入

您可能已经注意到,我们的 UserList 组件本身导入了一个名为 UserRow 的组件。虽然我们没有包含它的代码,但您可以看到它呈现了用户的姓名:之前的测试检查了“Sam”,但这并非直接在 UserList 中呈现,因此它必须来自 UserRow

A flowchart of how
  users' names move through our component.
UserListTest 不具有 UserRow 的可见性。

但是,UserRow 本身可能是一个复杂的组件——它可能获取进一步的用户数据,或者具有与我们的测试无关的副作用。删除这种可变性使您的测试更有帮助,尤其是在您要测试的组件变得更复杂并且与其依赖项更交织在一起时。

幸运的是,您可以使用 Vitest 来模拟某些导入,即使您的测试没有直接使用它们,这样任何使用它们的代码都会获得一个简单或已知的版本

vi.mock('./UserRow.tsx', () => {
  return {
    UserRow(arg) {
      return <>{arg.u.name}</>;
    },
  }
});

test('render', async () => {
  // ...
});

就像模拟 fetch 全局变量一样,这是一个强大的工具,但如果您的代码有很多依赖项,它可能会变得难以为继。同样,最好的解决方案是编写可测试的代码。

单击并提供上下文

React 和其他库(例如 Lit)都有一个称为 Context 的概念。示例代码包括 UserContext,如果选择了用户,它会调用方法。这通常被视为“prop drilling”的替代方案,“prop drilling”是指将回调直接传递给 UserList

我们的测试工具尚未提供 UserContext。通过在没有它的情况下向 React 测试添加单击操作,最坏的情况可能会导致测试崩溃。最好的情况是,如果在其他地方提供了默认实例,它可能会导致一些超出我们控制的行为(类似于上面未知的 UserRow)。

  const c = render(<UserList />);
  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();

相反,在呈现组件时,您可以提供您自己的 Context。此示例使用 vi.fn() 的实例,即 Vitest 模拟函数,可用于检查是否进行了调用以及它使用了哪些参数。

在我们的例子中,这与早期示例中模拟的 fetch 交互,并且测试可以确认传递的 ID 是 sam

  const userChosenFn = vi.fn();
  const ucForTest: ContextType<typeof UserContext> = { userChosen: userChosenFn as any };
  const c = render(
    <UserContext.Provider value={ucForTest}>
      <UserList />
    </UserContext.Provider>,
  );

  const chooseButton = await c.getByText(/Choose);
  chooseButton.click();
  assert.deepStrictEqual(userChosenFn.mock.calls, [['sam']]);

这是一个简单但功能强大的模式,可以让您从您尝试测试的核心组件中删除不相关的依赖项。

总结

此示例演示了如何构建组件测试来测试和保护难以测试的 React 组件。此测试的重点是确保组件与其依赖项正确交互:fetch 全局变量、导入的子组件和 Context

检查您的理解

使用哪些方法来测试 React 组件?

使用简单的依赖项模拟复杂的依赖项以进行测试
使用 Context 的依赖项注入
存根全局变量
检查数字是否递增