组件测试实际应用

如需演示实用的测试代码,您可以从组件测试入手。组件测试比简单的单元测试更重要,比端到端测试更复杂,并且会演示与 DOM 的交互。从更理论上说,使用 React 让 Web 开发者能够更轻松地将网站或 Web 应用视为由组件组成。

因此,在考虑测试新应用或现有应用时,测试各个组件(无论它们有多复杂)都是不错的方法。

本页介绍了如何测试具有复杂外部依赖项的小型组件。测试不与任何其他代码交互的组件非常简单,例如通过点击按钮并确认数字增加即可。实际上,这样的代码很少,而测试没有互动的代码的价值可能很有限。

(本文并非完整教程,我们在后面的章节中介绍“自动化测试的实践内容”部分,其中将逐步介绍如何通过示例代码来测试真实网站。不过,本页仍会介绍几个实际的组件测试示例。)

被测组件

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

名称列表,每个名称旁边都有一个“选择”按钮。
显示网络用户列表的小型 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 来响应来自网络的数据。
  • 确认点击用户会触发回调。

每个组件都各不相同。测试这个 API 的原因是什么?

  • 它使用全局 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.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。如果在没有点击操作的情况下向 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 实现依赖项注入
打桩全局变量
检查数字是否已递增