测试
随着代码库的扩展,你意想不到的小错误和边缘情况可能会累积成更大的故障。bug 会导致糟糕的用户体验,最终造成业务损失。防止脆弱编程的一种方法是在代码发布到生产之前先对其进行测试。
在本指南中,我们将涵盖确保你的应用按预期工作的不同自动化方法,从静态分析到端到端测试。
为什么测试
我们是人,人会犯错。测试很重要,因为它帮助你发现这些错误,并验证代码是否正常工作。也许更重要的是,测试确保了当你添加新功能、重构现有代码或升级项目的主要依赖时,代码仍然能够正常运行。
测试的价值可能比你想象的还要大。修复代码中 bug 的最佳方法之一是先编写一个失败的测试用例来暴露该 bug。当你修复 bug 并重新运行测试,如果测试通过,就意味着 bug 已被修复,并且不会重新引入代码库。
测试也可以作为新成员加入团队时的文档。对于从未见过该代码库的人,阅读测试可以帮助他们理解现有代码的工作方式。
最后,更多的自动化测试意味着更少的人工 质量保证 时间,从而释放出宝贵的时间。
静态分析
提升代码质量的第一步是开始使用静态分析工具。静态分析在你编写代码时检查错误,但不会执行代码。
- Lint 工具 分析代码以捕获常见错误,如未使用的代码,帮助避免陷阱,标记不符合样式规范的情况,例如使用制表符代替空格(或根据配置相反)。
- 类型检查 确保你传给函数的构造符合该函数设计接受的类型,比如防止将字符串传给预期数字参数的计数函数。
React Native 默认配置了两种此类工具:ESLint 用于代码风格检查,TypeScript 用于类型检查。
编写可测试的代码
想要开始测试,首先要编写可测试的代码。想象飞机制造流程——在任何型号首次起飞以展示所有复杂系统良好协作之前,会先测试各个部件以保证它们的安全性和功能性。例如,通过极限弯曲测试机翼;测试发动机部件的耐久性;通过模拟鸟撞击测试挡风玻璃。
软件也类似。不是把整个程序写在一个庞大的文件里,而是将代码拆分成多个小模块,你可以更充分地测试这些模块,相较于测试整合后的整体。这样,编写可测试的代码就和编写干净、模块化的代码紧密相连。
为了让应用更容易测试,先从将应用的视图部分——即 React 组件——与业务逻辑和应用状态分离开始(无论你使用 Redux、MobX 还是其他方案)。这样,可以保持业务逻辑的测试独立于依赖 React 组件的渲染部分,后者主要负责渲染 UI。
理论上,你可以将所有逻辑和数据抓取代码移出组件,使组件仅专注于渲染,状态完全独立于组件。这样,应用逻辑可以在没有任何 React 组件的情况下工作!
我们鼓励你在其他学习资源中进一步探索可测试代码的相关主题。
编写测试
编写了可测试的代码后,就可以编写实际的测试了!React Native 的默认模板自带 Jest 测试框架,其中包括针对该环境定制的预设配置,让你无需立即调试配置和 Mock 就能高效开发——稍后会详细介绍模拟 Mock。你可以用 Jest 来编写本指南所涉及的所有类型的测试。
如果你采用测试驱动开发(TDD),实际上是先编写测试!这样会自然而然地保证代码的可测试性。
测试结构
测试应简短且理想情况下只测试一件事。以下是用 Jest 编写的示例单元测试:
it('给定一个过去的日期,colorForDueDate() 返回红色', () => {
expect(colorForDueDate('2000-10-20')).toBe('red');
});
测试由传给 it 函数的字符串描述。务必写清楚测试内容,尽可能涵盖以下三部分:
- Given - 前置条件
- When - 被测试函数执行的动作
- Then - 预期结果
这也称为 AAA(Arrange,Act,Assert)。
Jest 提供 describe 函数帮助组织测试。用 describe 将属于同一功能的测试分组。describe 可以嵌套。你常用的还有 beforeEach 和 beforeAll 用于准备被测试的对象。详情请参阅 Jest API 参考。
测试步骤或断言过多时,建议拆分成多个小测试。确保测试之间完全独立。测试套件中的每个测试都必须能单独执行,无需依赖其他测试。反之,同时执行所有测试时,第一个测试的结果不应影响第二个测试。
最后,作为开发者,我们喜欢代码稳定,不出错。测试中情况往往相反。失败的测试是【好事】!测试失败往往意味着存在问题,这给你机会在用户受影响前修复。
单元测试
单元测试覆盖最小代码单位,如函数或类。
当被测试对象有依赖,会经常用到模拟(Mock),详情见下一段。
单元测试的优点是编写和运行速度快。这样你能快速得到测试是否通过的反馈。Jest 甚至支持只持续运行与你编辑代码相关的测试:监听模式。
模拟 Mock
有时被测对象依赖外部资源,这时想“模拟”它们。“模拟”指用你自定义的代码替代依赖对象。
通常来说,测试中使用真实对象优于 Mock,但有些情况不得不使用,比如 JS 单元测试依赖 Java 或 Objective-C 的原生模块。
假设你写了一个显示当前城市天气的应用,并依赖某第三方服务获取天气数据。如果服务返回“下雨”,你会显示带雨滴的云朵图片。你不希望测试时调用该服务,因为:
- 调用服务会导致测试慢且不稳定(网络请求问题)
- 服务每次返回数据可能不同
- 第三方服务可能宕机,而你急需运行测试!
所以,可使用一个模拟实现,替代成千上万行代码和联网设备!
Jest 支持从函数级别到模块级别的丰富模拟功能。
集成测试
构建大型软件系统时,组件间需要交互。单元测试中依赖项经常被 Mock 成假的。
集成测试则是将真实单元结合(就像在应用中一样)一起测试,确保它们配合正常。这里也会用到模拟(比如模拟天气服务的通信),但比单元测试用得少。
需要指出的是,集成测试在定义上并不总是完全一致。单元测试与集成测试的界线也不总是很明确。本指南中,符合以下条件的测试属于“集成测试”:
- 如上文描述,将多个应用模块组合测试
- 使用外部系统
- 向其他应用(如天气服务 API)发起网络请求
- 做任何文件或数据库 输入/输出 操作
组件测试
React 组件负责渲染应用,用户直接与其输出交互。即使业务逻辑测试覆盖率高且正确,没有组件测试依然可能让用户见到坏的 UI。组件测试既可算作单元测试,也可算作集成测试,但因其在 React Native 中核心地位,我们单独讲。
测试 React 组件时,你可能想测试两件事:
- 交互:确认组件用户交互(比如点击按钮)时表现正确
- 渲染:确认组件渲染输出(React 使用的)正确,比如按钮的样式与位置
例如,你有个带 onPress 监听器的按钮,你想测试按钮的正确展示及点击后被正确处理。
以下几个库有助于此类测试:
- React Native Testing Library 基于 React 测试渲染器并增加了
fireEvent和queryAPI(下一段会描述) - [已弃用] React 官方的 Test Renderer,可将组件渲染成纯 JavaScript 对象,无需依赖 DOM 或原生环境
组件测试仅在 Node.js 中运行的 JavaScript 测试,不考虑支撑 React Native 组件的 iOS、Android 或其他平台代码。因此它不能为你提供 100% 的信心。如果 iOS 或 Android 代码有缺陷,组件测试不会发现。
测试用户交互
除了渲染 UI,组件还处理事件,比如 TextInput 的 onChangeText 或 Button 的 onPress,还可能包含其他函数和事件回调。例如:
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 Testing Library 通过精心设计的 API 促进编写面向用户的测试。以下示例使用 fireEvent 的 changeText 和 press 方法模拟用户交互,并使用查询方法 getAllByText 在渲染输出中查找匹配的 Text 节点。
test('给定空的 GroceryShoppingList,用户可添加项目', () => {
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); // 期望列表中有“banana”
});
该示例不是测试调用函数后状态变化,而是测试用户在 TextInput 输入文本并点击 Button 后发生什么!
测试渲染输出
快照测试 是 Jest 提供的一种高级测试手段。它非常强大但底层,使用时需格外小心。
“组件快照”是由 Jest 内置的 React 序列化器生成的类似 JSX 的字符串。该序列化器将 React 组件树转换为易读字符串。换句话说,组件快照是测试运行时生成的组件渲染输出文本表示。示例:
<Text
style={
Object {
"fontSize": 20,
"textAlign": "center",
}
}>
Welcome to React Native!
</Text>
通常,你先完成组件实现,再运行快照测试。快照测试会创建快照并保存到代码仓库的文件中作为参考快照。该文件会提交并在代码审查时检查。以后组件渲染输出的任何改变都会导致快照更新,测试失败,你需要更新参考快照使测试通过。该更新也需提交并审查。
快照有若干缺陷:
- 你或审查者难判断快照变化是预期还是 bug。特别是快照很大时,难以理解且价值下降。
- 快照被创建时即视为正确,哪怕实际输出有误。
- 快照失败时,容易不加调查就用
--updateSnapshot参数更新,需开发者自律。
快照不能保证组件渲染逻辑正确,仅能防止意外变化,检查组件树中子组件获得的 props(样式等)是否如预期。
建议只用小型快照(见 no-large-snapshots 规则)。如果想测试两个组件状态间的变化,使用 snapshot-diff。不确定时,优先用上面提到的显式断言。
端到端测试
端到端(E2E)测试验证你的应用从用户角度在设备(或模拟器/仿真器)上运行是否正常。
这通过构建发布版本的应用并针对它运行测试完成。在 E2E 测试中,你不再关心 React 组件、React Native API、Redux 状态或业务逻辑。这些既不是 E2E 测试目的,也无法访问。
反而,E2E 测试库允许你查找并控制应用界面元素,例如实际点击按钮或在 TextInput 输入文本,仿真真实用户行为。然后你可以断言某元素是否存在、是否可见、包含什么文本等等。
E2E 测试提供最高的应用功能运行保证。缺点是:
- 编写耗时,较其他测试更费力
- 运行速度慢
- 容易出现“易变”测试(测试结果随机通过或失败,无代码变动)
建议用 E2E 测试覆盖应用关键部分:认证流程、核心功能、支付等。对非关键部分用速度更快的 JS 测试。测试越多信心越强,但维护和执行时间也越长。权衡利弊,选择适合你的方案。
React Native 社区有多个 E2E 工具,其中 Detox 因针对 React Native 设计而流行。其他 iOS/Android 端常用的有 Appium 和 Maestro。
总结
希望你喜欢阅读本指南并有所收获。测试你应用的方法多样,刚开始可能难以抉择。但相信随着给你出色的 React Native 应用添加测试,一切都会变得明朗。还等什么?赶快提升你的测试覆盖率吧!
链接
本指南最初由 Vojtech Novak 全权撰写和贡献。