组件测试实际应用

组件测试是一个很好的起点,可以用来演示实用的测试代码。 组件测试比简单的单元测试更丰富,比端到端测试更复杂,并且可演示与 DOM 的交互。从哲学层面来看,使用 React 让 Web 开发者更容易将网站或 Web 应用视为由组件构成。

因此,无论组件有多复杂,测试各个组件都是开始考虑测试新应用或现有应用的好方法。

本页将详细介绍如何测试具有复杂外部依赖项的小型组件。您可以轻松测试不与任何其他代码互动的组件,例如点击按钮并确认数字增加。实际上,很少有代码是这样的,而且测试没有互动的代码可能没有多大价值。

被测组件

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

名称列表,每个名称旁边都有一个“选择”按钮。
一个小型 React 组件,用于显示网络中的用户列表。

此 React 组件 UserList 会从网络中提取用户列表,并允许您选择其中之一。系统使用 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);
});

此测试断言,当组件呈现时,它包含“用户”文本。即使该组件会产生向网络发送 fetch 的副作用,它也能正常运行。测试结束时,fetch 仍在运行中,且没有设置端点。我们无法确认测试结束时是否会显示任何用户信息,至少在等待超时之前无法确认。

模拟 fetch()

模拟是指为进行测试而将真实函数或类替换为您控制的对象。除了最简单的单元测试之外,几乎所有类型的测试都采用这种做法。如需了解详情,请参阅断言和其他基元

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

在 vitest 中,您可以使用 vi.fn() 返回的特殊对象调用 vi.stubGlobal,以模拟全局变量,从而构建一个稍后可以修改的模拟对象。本课程的后续部分将更详细地介绍这些方法,但您可以在以下代码中实际看到它们:

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

一张流程图,显示用户姓名如何在我们的组件中流转。
UserListTest 不具备 UserRow 的可见性。

不过,UserRow 本身可能是一个复杂的组件,它可能会提取更多用户数据,或者具有与我们的测试无关的副作用。消除这种可变性有助于提高测试的有效性,尤其是当您要测试的组件变得越来越复杂且与其依赖项越来越交织时。

幸运的是,您可以使用 Vitest 模拟某些导入,即使您的测试未直接使用它们,也可以为使用它们的任何代码提供简单或已知版本:

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

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

与模拟 fetch 全局变量一样,这也是一款强大的工具,但如果您的代码有大量依赖项,则可能会变得不可持续。同样,最佳解决方法是编写可测试的代码。

点击并提供背景信息

React 和其他库(例如 Lit)都具有一个名为 Context 的概念。示例代码包含 UserContext,如果选择了用户,则会调用该方法。这通常被视为“属性钻研”的替代方案,其中回调会直接传递给 UserList

我们的测试框架未提供 UserContext。如果在没有 click 操作的情况下向 React 测试添加 click 操作,最糟糕的情况是测试会崩溃。如果在其他位置提供了默认实例,最坏的情况是,可能会导致某些行为超出我们的控制范围(类似于上面的未知 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 进行依赖项注入
对全局数据执行桩
使用简单的依赖项模拟复杂的依赖项以进行测试
检查数字是否递增