跳到主要内容

测试

随着代码库的扩展,意想不到的小错误和边缘情况可能会演变成更大的故障。缺陷会导致糟糕的用户体验,并最终造成业务损失。防止脆弱编程的一种方法是在发布代码之前对其进行测试。

本指南将介绍各种自动化方法,以确保您的应用按预期工作,范围从静态分析到端到端测试。

Testing is a cycle of fixing, testing, and either passing to release or failing back into testing.

为什么要测试

我们是人类,人类会犯错误。测试很重要,因为它能帮助你发现这些错误并验证你的代码是否正常工作。也许更重要的是,测试能确保你的代码在未来添加新功能、重构现有功能或升级项目主要依赖项时仍然能正常工作。

测试的价值可能比你想象的要大。修复代码中 bug 的最佳方法之一是编写一个失败的测试来暴露它。然后当你修复 bug 并重新运行测试时,如果它通过了,就意味着 bug 已被修复,并且永远不会被重新引入代码库。

测试也可以作为新加入团队成员的文档。对于从未接触过代码库的人来说,阅读测试可以帮助他们理解现有代码的工作方式。

最后但同样重要的是,更多自动化测试意味着更少的手动 QA 时间,从而节省宝贵的时间。

静态分析

提高代码质量的第一步是开始使用静态分析工具。静态分析会在您编写代码时检查错误,但无需运行任何代码。

  • 代码检查工具(Linters)分析代码以捕获常见错误,例如未使用的代码,并帮助避免陷阱,还会标记样式指南中的不规范之处,例如使用制表符而不是空格(反之亦然,具体取决于您的配置)。
  • 类型检查确保您传递给函数的构造与函数设计接受的类型匹配,例如防止将字符串传递给期望数字的计数函数。

React Native 开箱即用地配置了两个此类工具:用于代码检查的 ESLint 和用于类型检查的 TypeScript

编写可测试代码

要开始进行测试,首先需要编写可测试的代码。考虑飞机制造过程——在任何模型首次起飞以展示其所有复杂系统协同工作之前,单个部件都要经过测试,以确保它们安全且功能正常。例如,机翼在极端载荷下进行弯曲测试;发动机部件进行耐久性测试;挡风玻璃进行模拟鸟撞测试。

软件也类似。与其将整个程序编写在一个包含许多代码行的大文件中,不如将代码编写在多个小模块中,这样可以比测试整个组装好的程序更彻底地进行测试。通过这种方式,编写可测试的代码与编写清晰、模块化的代码是相互关联的。

为了使您的应用更易于测试,首先将应用的视图部分(即您的 React 组件)与业务逻辑和应用状态(无论您使用 Redux、MobX 还是其他解决方案)分开。这样,您可以使您的业务逻辑测试(不应依赖您的 React 组件)独立于组件本身,因为组件的主要工作是渲染应用的 UI!

理论上,你甚至可以将所有逻辑和数据获取移出组件。这样你的组件将专门用于渲染。你的状态将完全独立于你的组件。你的应用程序逻辑将完全不需要任何 React 组件即可运行!

我们鼓励您在其他学习资源中进一步探索可测试代码的主题。

编写测试

编写完可测试的代码后,是时候编写一些实际的测试了!React Native 的默认模板内置了 Jest 测试框架。它包含一个为此环境量身定制的预设,因此您可以立即高效工作,而无需调整配置和 mock(稍后详细介绍 mock)。您可以使用 Jest 编写本指南中介绍的所有类型的测试。

如果您进行测试驱动开发,实际上是先编写测试!这样,代码的可测试性就有了保障。

组织测试

您的测试应该简短,理想情况下只测试一件事。我们从一个用 Jest 编写的单元测试示例开始

js
it('given a date in the past, colorForDueDate() returns red', () => {
expect(colorForDueDate('2000-10-20')).toBe('red');
});

测试由传递给 it 函数的字符串描述。请仔细编写描述,使其清晰表明正在测试什么。尽力涵盖以下内容

  1. 给定 (Given) - 某些前置条件
  2. 当 (When) - 您正在测试的函数执行的某些动作
  3. 则 (Then) - 预期的结果

这也称为 AAA(准备、执行、断言)。

Jest 提供了 describe 函数来帮助组织您的测试。使用 describe 将属于同一功能的测试分组。如果需要,describe 可以嵌套。您常用的其他函数是 beforeEachbeforeAll,您可以用来设置要测试的对象。在 Jest API 参考中阅读更多内容。

如果您的测试有许多步骤或许多预期,您可能希望将其拆分为多个更小的测试。此外,请确保您的测试彼此完全独立。测试套件中的每个测试都必须能够独立执行,而无需先运行其他测试。反之,如果您同时运行所有测试,则第一个测试不得影响第二个测试的输出。

最后,作为开发者,我们喜欢代码运行良好且不崩溃。然而,对于测试来说,情况通常恰恰相反。将失败的测试视为一件好事!当测试失败时,通常意味着有些地方不对劲。这为您提供了在问题影响用户之前修复它的机会。

单元测试

单元测试覆盖代码的最小部分,例如单个函数或类。

当被测试对象有任何依赖项时,您通常需要对其进行 mock,如下一段所述。

单元测试的优点是编写和运行速度快。因此,在您工作时,您可以快速获得测试是否通过的反馈。Jest 甚至有一个选项可以持续运行与您正在编辑的代码相关的测试:监视模式

Mocking(模拟)

有时,当您测试的对象具有外部依赖项时,您会想要“mock 掉它们”。“Mocking”是指用您自己的实现替换代码的某些依赖项。

通常,在测试中使用真实对象优于使用 mock,但在某些情况下这不可能实现。例如:当您的 JS 单元测试依赖于用 Java 或 Objective-C 编写的原生模块时。

想象一下,您正在编写一个显示您所在城市当前天气的应用,并且您正在使用某个外部服务或其他依赖项来提供天气信息。如果服务告诉您正在下雨,您希望显示一张带有雨云的图片。您不想在测试中调用该服务,因为

  • 这可能会使测试变慢且不稳定(因为涉及网络请求)
  • 每次运行测试时,服务可能会返回不同的数据
  • 第三方服务在您真正需要运行测试时可能会下线!

因此,您可以提供该服务的 mock 实现,有效地替代数千行代码和一些联网的温度计!

Jest 支持从函数级别到模块级别的 mock

集成测试

在编写大型软件系统时,其各个部分需要相互交互。在单元测试中,如果您的单元依赖于另一个单元,您有时会 mock 掉该依赖项,用一个假的替代它。

在集成测试中,真实的单个单元被组合起来(与您的应用中相同),并一起进行测试,以确保它们的协作按预期工作。这并不是说这里不会发生 mock:您仍然需要 mock(例如,模拟与天气服务的通信),但您对它们的需求将远低于单元测试。

请注意,关于集成测试含义的术语并不总是统一的。此外,单元测试和集成测试之间的界限也可能不总是清晰的。对于本指南,如果您的测试符合以下条件,则属于“集成测试”:

  • 如上所述,组合了您应用的多个模块
  • 使用了外部系统
  • 向其他应用程序(例如天气服务 API)发出网络调用
  • 执行任何类型的文件或数据库 I/O

组件测试

React 组件负责渲染您的应用,用户将直接与其输出进行交互。即使您的应用业务逻辑具有很高的测试覆盖率并且是正确的,如果没有组件测试,您仍然可能会向用户交付损坏的 UI。组件测试可能属于单元测试和集成测试,但由于它们是 React Native 的核心部分,我们将单独介绍它们。

要测试 React 组件,您可能需要测试两件事

  • 交互:确保组件在用户交互时行为正确(例如,当用户按下按钮时)
  • 渲染:确保 React 使用的组件渲染输出正确(例如,按钮在 UI 中的外观和位置)

例如,如果您有一个带有 onPress 监听器的按钮,您希望测试该按钮的显示是否正确,以及组件是否正确处理了按钮的点击事件。

有几个库可以帮助您进行这些测试

组件测试只是在 Node.js 环境中运行的 JavaScript 测试。它们考虑支持 React Native 组件的任何 iOS、Android 或其他平台代码。因此,它们无法给您 100% 的信心,确保所有功能对用户都正常工作。如果 iOS 或 Android 代码中存在错误,它们将无法找到。

测试用户交互

除了渲染某些 UI 外,您的组件还处理事件,例如 TextInputonChangeTextButtononPress。它们也可能包含其他函数和事件回调。请考虑以下示例

tsx
function GroceryShoppingList() {
const [groceryItem, setGroceryItem] = useState('');
const [items, setItems] = useState<string[]>([]);

const addNewItemToShoppingList = useCallback(() => {
setItems([groceryItem, ...items]);
setGroceryItem('');
}, [groceryItem, items]);

return (
<>
<TextInput
value={groceryItem}
placeholder="Enter grocery item"
onChangeText={text => setGroceryItem(text)}
/>
<Button
title="Add the item to list"
onPress={addNewItemToShoppingList}
/>
{items.map(item => (
<Text key={item}>{item}</Text>
))}
</>
);
}

在测试用户交互时,应从用户角度测试组件——页面上有什么?交互时发生了什么变化?

根据经验法则,优先使用用户可以看到或听到的东西

相反,您应该避免

  • 对组件的 props 或 state 进行断言
  • testID 查询

避免测试像 props 或 state 这样的实现细节——虽然这类测试有效,但它们不关注用户将如何与组件交互,并且在重构时容易中断(例如,当您想要重命名某些内容或使用 Hook 重写类组件时)。

React 类组件特别容易测试其实现细节,如内部状态、props 或事件处理程序。为避免测试实现细节,优先使用带有 Hooks 的函数组件,这使得依赖组件内部实现变得更难

诸如 React Native 测试库(React Native Testing Library)之类的组件测试库通过精心选择提供的 API,有助于编写以用户为中心的测试。以下示例使用了 fireEvent 方法 changeTextpress,它们模拟用户与组件交互,以及一个查询函数 getAllByText,它在渲染输出中查找匹配的 Text 节点。

tsx
test('given empty GroceryShoppingList, user can add an item to it', () => {
const {getByPlaceholderText, getByText, getAllByText} = render(
<GroceryShoppingList />,
);

fireEvent.changeText(
getByPlaceholderText('Enter grocery item'),
'banana',
);
fireEvent.press(getByText('Add the item to list'));

const bananaElements = getAllByText('banana');
expect(bananaElements).toHaveLength(1); // expect 'banana' to be on the list
});

这个例子并没有测试当你调用一个函数时某些状态是如何变化的。它测试的是当用户改变 TextInput 中的文本并按下 Button 时会发生什么!

测试渲染输出

快照测试(Snapshot testing)是 Jest 支持的一种高级测试。它是一个非常强大且底层的工具,因此在使用时建议格外注意。

“组件快照”是由 Jest 内置的自定义 React 序列化器创建的 JSX 类似字符串。此序列化器让 Jest 将 React 组件树转换为人类可读的字符串。换句话说:组件快照是您的组件渲染输出在测试运行期间生成的文本表示。它可能看起来像这样

tsx
<Text
style={
Object {
"fontSize": 20,
"textAlign": "center",
}
}>
Welcome to React Native!
</Text>

通过快照测试,您通常会首先实现您的组件,然后运行快照测试。快照测试会创建一个快照并将其保存到您的代码仓库中作为一个参考快照文件。该文件随后会被提交并在代码审查期间进行检查。未来对组件渲染输出的任何更改都会改变其快照,这将导致测试失败。然后,您需要更新存储的参考快照以使测试通过。该更改同样需要提交并审查。

快照有几个弱点

  • 对于开发者或评审者来说,很难判断快照中的更改是预期行为还是 bug 的证据。特别是大型快照,很快就会变得难以理解,其附加价值也随之降低。
  • 当创建快照时,它在那个时刻被认为是正确的——即使渲染输出实际上是错误的。
  • 当快照失败时,很容易直接使用 --updateSnapshot Jest 选项更新它,而不去仔细调查更改是否符合预期。因此,需要一定的开发者自律。

快照本身并不能确保您的组件渲染逻辑是正确的,它们仅仅擅长防止意外更改,并检查被测试的 React 树中的组件是否接收到预期的 props(样式等)。

我们建议您只使用小型快照(参见 no-large-snapshots 规则)。如果您想测试两个 React 组件状态之间的变化,请使用 snapshot-diff。如有疑问,请优先使用上一段中描述的明确预期。

端到端测试

在端到端 (E2E) 测试中,您从用户角度验证您的应用在设备(或模拟器/仿真器)上是否按预期工作。

这是通过在发布配置中构建您的应用并对其运行测试来完成的。在 E2E 测试中,您不再考虑 React 组件、React Native API、Redux 存储或任何业务逻辑。这并非 E2E 测试的目的,而且在 E2E 测试期间这些也无法访问。

相反,E2E 测试库允许您查找和控制应用程序屏幕中的元素:例如,您可以实际地点击按钮或将文本插入 TextInputs,就像真实用户一样。然后,您可以对应用程序屏幕中是否存在某个元素、是否可见、包含什么文本等进行断言。

E2E 测试能让您对应用的部分功能正常运行获得最高的信心。权衡包括

  • 与其他类型的测试相比,编写它们更耗时
  • 它们运行速度较慢
  • 它们更容易出现不稳定性(“不稳定”测试是指在代码没有任何更改的情况下随机通过和失败的测试)

尝试用 E2E 测试覆盖您应用程序的关键部分:认证流程、核心功能、支付等。对于非关键部分,使用更快的 JS 测试。您添加的测试越多,信心越高,但同时,您花费在维护和运行它们上的时间也越多。权衡利弊并决定什么最适合您。

有几种 E2E 测试工具可用:在 React Native 社区中,Detox 是一个流行的框架,因为它专为 React Native 应用量身定制。iOS 和 Android 应用领域中另一个流行的库是 AppiumMaestro

总结

我们希望您喜欢阅读本指南并从中有所收获。测试您的应用有许多方法。一开始可能很难决定使用哪种方法。但是,我们相信一旦您开始为您的出色 React Native 应用添加测试,一切都会变得有意义。那么,还在等什么呢?提高您的测试覆盖率吧!


本指南最初由 Vojtech Novak 全权撰写和贡献。