React 集成测试:更广的覆盖率,更少的测试

Avatar of Sarah Mogin
Sarah Mogin 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

集成测试非常适合交互式网站,例如您可能使用 React 构建的网站。它们验证用户如何与您的应用程序交互,而无需端到端测试的开销。

本文遵循一个练习,该练习从一个简单的网站开始,使用单元测试和集成测试验证行为,并演示集成测试如何从更少的代码行中获得更大的价值。内容假设您熟悉 React 和 JavaScript 中的测试。了解 JestReact Testing Library 会有所帮助,但不是必需的。

有三种类型的测试

  • 单元测试 验证一段代码在隔离状态下的行为。它们易于编写,但可能 忽略大局
  • 端到端测试 (E2E) 使用自动化框架(例如 CypressSelenium)像用户一样与您的网站交互:加载页面、填写表单、点击按钮等。它们通常编写和运行速度较慢,但与真实的 用户体验非常接近。
  • 集成测试 介于两者之间。它们验证应用程序的多个单元如何协同工作,但比 E2E 测试更轻量级。例如,Jest 附带了一些内置实用程序来促进集成测试;Jest 在后台使用 jsdom 来模拟常见的浏览器 API,其开销小于自动化,并且其强大的模拟工具可以存根外部 API 调用。

另一个问题:在 React 应用程序中,单元测试和集成测试的 编写方式相同,使用相同的工具。

React 测试入门

我创建了一个简单的 React 应用程序(可在 GitHub 上获取),其中包含一个登录表单。我将其连接到 reqres.in,这是一个我发现用于测试前端项目的便捷 API。

您可以成功登录

…或遇到来自 API 的错误消息

代码结构如下

LoginModule/
├── components/
⎪   ├── Login.js // renders LoginForm, error messages, and login confirmation
⎪   └── LoginForm.js // renders login form fields and button
├── hooks/
⎪    └── useLogin.js // connects to API and manages state
└── index.js // stitches everything together

选项 1:单元测试

如果您像我一样,喜欢编写测试——也许戴着耳机,在 Spotify 上听一些好东西——那么您可能会忍不住为每个文件编写一个单元测试。

即使您不是测试爱好者,您也可能正在参与一个“试图做好测试”的项目,但没有明确的策略,测试方法是“我想每个文件都应该有自己的测试?”

这看起来像这样(我添加了 unit 到测试文件名以提高清晰度)

LoginModule/
├── components/
⎪   ├── Login.js
⎪   ├── Login.unit.test.js
⎪   ├── LoginForm.js
⎪   └── LoginForm.unit.test.js
├── hooks/
⎪   ├── useLogin.js 
⎪   └── useLogin.unit.test.js
├── index.js
└── index.unit.test.js

我在 GitHub 上完成了添加所有这些单元测试的练习,并创建了一个 test:coverage:unit 脚本以生成覆盖率报告(Jest 的内置功能)。我们可以通过四个单元测试文件获得 100% 的覆盖率。

100% 的覆盖率通常是过度的,但对于如此简单的代码库来说是可以实现的。

让我们深入研究为 onLogin React hook 创建的其中一个单元测试。如果您不熟悉 React hook 或如何测试它们,请不要担心。

test('successful login flow', async () => {
  // mock a successful API response
  jest
    .spyOn(window, 'fetch')
    .mockResolvedValue({ json: () => ({ token: '123' }) });


  const { result, waitForNextUpdate } = renderHook(() => useLogin());


  act(() => {
    result.current.onSubmit({
      email: '[email protected]',
      password: 'password',
    });
  });


  // sets state to pending
  expect(result.current.state).toEqual({
    status: 'pending',
    user: null,
    error: null,
  });


  await waitForNextUpdate();


  // sets state to resolved, stores email address
  expect(result.current.state).toEqual({
    status: 'resolved',
    user: {
      email: '[email protected]',
    },
    error: null,
  });
});

这个测试写起来很有趣(因为 React Hooks Testing Library 使测试 hook 变得轻而易举),但它也存在一些问题。

首先,测试验证了一段内部状态从 'pending' 更改为 'resolved';此实现细节未公开给用户,因此可能不是一个好的测试对象。如果我们重构应用程序,我们将不得不更新此测试,即使从用户的角度来看没有任何变化。

此外,作为单元测试,这只是其中一部分。如果我们想验证登录流程的其他功能,例如提交按钮文本更改为“加载”,则必须在不同的测试文件中进行。

选项 2:集成测试

让我们考虑添加一个集成测试来验证此流程的替代方法。

LoginModule/
├── components/
⎪   ├─ Login.js
⎪   └── LoginForm.js
├── hooks/
⎪   └── useLogin.js 
├── index.js
└── index.integration.test.js

实现了此测试和一个 test:coverage:integration 脚本以生成覆盖率报告。就像单元测试一样,我们可以获得 100% 的覆盖率,但这次都在一个文件中,并且需要更少的代码行。

这是涵盖成功登录流程的集成测试。

test('successful login', async () => {
  jest
    .spyOn(window, 'fetch')
    .mockResolvedValue({ json: () => ({ token: '123' }) });

  render(<LoginModule />);

  const emailField = screen.getByRole('textbox', { name: 'Email' });
  const passwordField = screen.getByLabelText('Password');
  const button = screen.getByRole('button');

  // fill out and submit form
  fireEvent.change(emailField, { target: { value: '[email protected]' } });
  fireEvent.change(passwordField, { target: { value: 'password' } });
  fireEvent.click(button);

  // it sets loading state
  expect(button).toBeDisabled();
  expect(button).toHaveTextContent('Loading...');

  await waitFor(() => {
    // it hides form elements
    expect(button).not.toBeInTheDocument();
    expect(emailField).not.toBeInTheDocument();
    expect(passwordField).not.toBeInTheDocument();

    // it displays success text and email address
    const loggedInText = screen.getByText('Logged in as');
    expect(loggedInText).toBeInTheDocument();
    const emailAddressText = screen.getByText('[email protected]');
    expect(emailAddressText).toBeInTheDocument();
  });
});

我真的很喜欢这个测试,因为它从用户的角度验证了整个登录流程:表单、加载状态和成功确认消息。集成测试非常适合 React 应用程序,正是因为这种用例;用户体验是我们想要测试的 内容,而这几乎总是涉及 多个不同的代码部分协同工作

此测试不了解使预期行为生效的组件或 hook,这很好。只要用户体验保持不变,我们就可以重写和重构这些实现细节,而不会破坏测试。

我不会深入研究登录流程的其他集成测试的 初始状态错误处理,但我鼓励您在 GitHub 上查看它们。

那么,什么 需要 单元测试?

与其考虑单元测试与集成测试,不如退一步思考我们如何决定首先需要测试什么。LoginModule 需要测试,因为它是一个我们希望使用者(应用程序中的其他文件)能够放心地使用的实体。

另一方面,onLogin hook 不需要测试,因为它只是 LoginModule 的实现细节。但是,如果我们的需求发生变化,并且 onLogin 在其他地方有用例,那么我们希望添加我们自己的(单元)测试来验证其作为可重用实用程序的功能。(我们也希望移动文件,因为它不再特定于 LoginModule 了。)

单元测试仍然有很多用例,例如需要验证可重用选择器、hook 和普通函数。在开发代码时,您可能还会发现使用单元测试练习测试驱动开发很有帮助,即使您稍后将该逻辑向上移动到集成测试。

此外,单元测试在针对多个输入和用例进行详尽测试方面做得很好。例如,如果我的表单需要针对各种场景显示内联验证(例如无效电子邮件、缺少密码、密码过短),我将在集成测试中涵盖一个代表性案例,然后在单元测试中深入研究具体案例。

其他好东西

趁此机会,我想谈谈一些有助于我的集成测试保持清晰和组织的语法技巧。

明确的 waitFor 块

我们的测试需要考虑 LoginModule 的加载状态和成功状态之间的延迟。

const button = screen.getByRole('button');
fireEvent.click(button);


expect(button).not.toBeInTheDocument(); // too soon, the button is still there!

我们可以使用 DOM Testing Library 的 waitFor 帮助程序来实现这一点。

const button = screen.getByRole('button');
fireEvent.click(button);


await waitFor(() => {
  expect(button).not.toBeInTheDocument(); // ahh, that's better
});

但是,如果我们还想测试其他一些项目呢?网上没有很多关于如何处理此问题的好的示例,在过去的项目中,我会将其他项目放在 waitFor 之外。

// wait for the button
await waitFor(() => {
  expect(button).not.toBeInTheDocument();
});


// then test the confirmation message
const confirmationText = getByText('Logged in as [email protected]');
expect(confirmationText).toBeInTheDocument();

这可以工作,但我不喜欢它,因为它使按钮条件看起来很特殊,即使我们可以轻松地切换这些语句的顺序。

// wait for the confirmation message
await waitFor(() => {
  const confirmationText = getByText('Logged in as [email protected]');
  expect(confirmationText).toBeInTheDocument();
});


// then test the button
expect(button).not.toBeInTheDocument();

在我看来,最好将与同一更新相关的所有内容都分组到 waitFor 回调中。

await waitFor(() => {
  expect(button).not.toBeInTheDocument();
  
  const confirmationText = screen.getByText('Logged in as [email protected]');
  expect(confirmationText).toBeInTheDocument();
});

对于像这样的简单断言,我真的很喜欢这种技术,但它可能会在某些情况下减慢测试速度,等待在 waitFor 之外立即发生的失败。请参阅 React Testing Library 的常见错误中的“在单个 waitFor 回调中有多个断言”以了解此示例。

对于包含几个步骤的测试,我们可以在一行中使用多个 waitFor 块。

const button = screen.getByRole('button');
const emailField = screen.getByRole('textbox', { name: 'Email' });


// fill out form
fireEvent.change(emailField, { target: { value: '[email protected]' } });


await waitFor(() => {
  // check button is enabled
  expect(button).not.toBeDisabled();
  expect(button).toHaveTextContent('Submit');
});


// submit form
fireEvent.click(button);


await waitFor(() => {
  // check button is no longer present
  expect(button).not.toBeInTheDocument();
});

如果您只等待一个项目出现,则可以使用 findBy 查询代替。它在后台使用 waitFor

内联 it 注释

另一种测试最佳实践是编写更少、更长的测试;这使您可以将测试用例与重要的用户流程相关联,同时保持测试隔离以避免意外行为。我赞同这种方法,但它在保持代码组织和记录所需行为方面可能会带来挑战。我们需要未来的开发人员能够返回测试并了解它在做什么,为什么它失败了等等。

例如,假设其中一个预期开始失败

it('handles a successful login flow', async () => {
  // beginning of test hidden for clarity


  expect(button).toBeDisabled();
  expect(button).toHaveTextContent('Loading...');


  await waitFor(() => {
    expect(button).not.toBeInTheDocument();
    expect(emailField).not.toBeInTheDocument();
    expect(passwordField).not.toBeInTheDocument();


    const confirmationText = screen.getByText('Logged in as [email protected]');
    expect(confirmationText).toBeInTheDocument();
  });
});

查看此内容的开发人员无法轻松确定正在测试的内容,并且可能难以确定失败是错误(意味着我们应该修复代码)还是行为更改(意味着我们应该修复测试)。

我解决此问题的最喜欢的方法是对每个测试使用鲜为人知的test语法,并添加内联it样式注释来描述每个正在测试的关键行为

test('successful login', async () => {
  // beginning of test hidden for clarity


  // it sets loading state
  expect(button).toBeDisabled();
  expect(button).toHaveTextContent('Loading...');


  await waitFor(() => {
    // it hides form elements
    expect(button).not.toBeInTheDocument();
    expect(emailField).not.toBeInTheDocument();
    expect(passwordField).not.toBeInTheDocument();


    // it displays success text and email address
    const confirmationText = screen.getByText('Logged in as [email protected]');
    expect(confirmationText).toBeInTheDocument();
  });
});

这些注释不会神奇地与 Jest 集成,因此,如果您遇到失败,失败的测试名称将对应于您传递给test标签的参数,在本例中为'successful login'。但是,Jest 的错误消息包含周围的代码,因此这些it注释仍然有助于识别失败的行为。这是当我从其中一个预期中删除not时收到的错误消息

为了获得更明确的错误,有一个名为jest-expect-message的包,它允许您为每个预期定义错误消息

expect(button, 'button is still in document').not.toBeInTheDocument();

一些开发人员更喜欢这种方法,但我发现在大多数情况下它有点细粒度了,因为单个it通常涉及多个预期。

团队的下一步

有时我希望我们可以为人类制定 linter 规则。如果是这样,我们可以为我们的团队设置一个 prefer-integration-tests 规则,然后就完成了。

但可惜的是,我们需要找到一个更类似的解决方案来鼓励开发人员在类似于我们之前介绍的LoginModule示例的情况下选择集成测试。像大多数事情一样,这归结为团队讨论您的测试策略,就对项目有意义的内容达成一致,并——希望——在ADR中记录下来。

在制定测试计划时,我们应该避免一种迫使开发人员为每个文件编写测试的文化。开发人员需要能够放心地做出明智的测试决策,而不用担心他们“测试不足”。Jest 的覆盖率报告可以通过提供一个健全性检查来帮助解决此问题,即使测试在集成级别得到整合,您也可以实现良好的覆盖率。

我仍然不认为自己是集成测试方面的专家,但完成此练习帮助我分解了一个集成测试比单元测试提供更大价值的用例。我希望与您的团队分享这一点,或者在您的代码库中进行类似的练习,将有助于指导您将集成测试纳入您的工作流程。