集成测试非常适合交互式网站,例如您可能使用 React 构建的网站。它们验证用户如何与您的应用程序交互,而无需端到端测试的开销。
本文遵循一个练习,该练习从一个简单的网站开始,使用单元测试和集成测试验证行为,并演示集成测试如何从更少的代码行中获得更大的价值。内容假设您熟悉 React 和 JavaScript 中的测试。了解 Jest 和 React Testing Library 会有所帮助,但不是必需的。
有三种类型的测试
- 单元测试 验证一段代码在隔离状态下的行为。它们易于编写,但可能 忽略大局。
- 端到端测试 (E2E) 使用自动化框架(例如 Cypress 或 Selenium)像用户一样与您的网站交互:加载页面、填写表单、点击按钮等。它们通常编写和运行速度较慢,但与真实的 用户体验非常接近。
- 集成测试 介于两者之间。它们验证应用程序的多个单元如何协同工作,但比 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 的覆盖率报告可以通过提供一个健全性检查来帮助解决此问题,即使测试在集成级别得到整合,您也可以实现良好的覆盖率。
我仍然不认为自己是集成测试方面的专家,但完成此练习帮助我分解了一个集成测试比单元测试提供更大价值的用例。我希望与您的团队分享这一点,或者在您的代码库中进行类似的练习,将有助于指导您将集成测试纳入您的工作流程。
你好,
非常棒的文章!
我也在尝试为我的应用程序编写集成 UI 测试!但我遇到了一个问题,即模拟始终在更改,因此我需要不断更新我的 json 模拟,然后集成测试变得毫无用处。
您如何处理 API 模拟?
谢谢 Mariam!
我想这取决于模拟总是变化的原因。如果您的 API 尚未稳定,并且您觉得在更新模拟和测试方面浪费了时间,那么您可能希望禁用测试,直到达到稳定点。如果模拟兼作测试的模拟和 UI 的模拟(在 API 构建过程中,也许),那么您需要拆分模拟,以便像调整标题这样的小更改不会破坏您的测试。Jest 的通用匹配项(如“expect.any”和“expect.objectContaining”)也可以帮助创建更灵活的测试。希望这有帮助!
很棒的文章,我在我的项目中使用相同的库,我也相信测试行为比测试实现更好,以获得重构证明测试;-)。
不过有一件事,而不是使用关键字“test”和注释中的每个“it”编写测试。您只需将您的测试包装在一个“describe”中并使用“it”关键字继续声明它们即可。这样,控制台输出会准确显示场景的哪个步骤已损坏,而无需编写注释并维护它们。
感谢您的阅读!
我认为在这种情况下使用 describe/it 从技术上讲是可行的,并且肯定可以生成不错的控制台输出,但我犹豫是否要在项目范围内这样做,因为任何“it”/“test”块在 Jest 中都算作测试,并且会触发 beforeEach/afterEach 标志,或者类似于 clearMocks 配置标志(我倾向于使用:https://jest.node.org.cn/docs/en/configuration.html#clearmocks-boolean)。
有趣的文章,但我要说我越来越不热衷于集成测试了。我的意思是它们的目的究竟是什么。它们测试 UI 的一部分,但处于隔离状态。因此,它们取决于开发人员的意愿和知识。我认为测试可能以更大的方式进行。通过单元测试,我可以测试纯逻辑,通常更容易使用纯函数等来测试。它鼓励您拥有内部逻辑元素很少的视图。因此,您的哑组件应该只是您更复杂逻辑的反映。此外,单元测试运行和修复的速度要快得多。另一方面,我认为 e2e 测试更适合您的用例,并且它们在实际浏览器中运行!当然,它们的运行速度也慢得多。但是,如果您需要关键路径,我认为它们是最好的。我在这里的观点并不是说你错了,我的意思是如果它对你有用,那就太好了。但我认为测试应该属于一个更大的范围,这个范围远大于开发时间。
有趣的观点!我认为这取决于您的应用程序的形状。通常在我的应用程序中,我有一些复杂的组件或模块,在集成级别对其进行测试是有意义的,因为它们可以出现在多个页面上,但我仍然需要一起测试多个部分。
这不是真正的集成测试,因为它不会在浏览器上运行,您无法测试许多可能场景的全部范围,例如在 Portal 中打开另一个组件的组件,并且该组件具有依赖于 DOM api 或什么的动态测量。
对于这些开发人员,像我一样,近年来一直在使用 Jest+Puppeteer。
我认为“集成测试”根据你问谁有几个定义,但我倾向于称你所说的端到端或功能测试。我几乎认同这篇博文中给出的细分:https://kentcdodds.com/blog/unit-vs-integration-vs-e2e-tests
不过 Puppeteer 确实很酷!
好文章!