单元测试在 React-Native APP 中调研及实践
-
背景及目标
背景
- 最近参与的公司一个内部 APP的开发,经过团队 7 人半年多快速的迭代,已产生 65k+ 行的代码量,服务目标用户预计超过万人。项目由于迭代紧凑,会存在频繁增量发版需求(最多时一周会有3次)。
- 伴随着功能的逐渐叠加,上线前的回归就显得越来越重要,但是上线前的回归测试严重依赖于测试人力,会导致大量重复人力的投入,使得团队效率降低,而且如果人工测试用例覆盖率不足,就可能随时导致线上问题出现。
- 同时,频繁发版不可避免的也会导致一些回归用例不到位,影响工程师正常工作,打扰团队成员发版后的夜半美梦。长此以往只会让团队逐渐疲惫,打击团队信心,影响团队口碑。
目标
- 为核心模块增加单元测试,避免出现线上低级错误,提升工作效率,保障项目依赖的公共资源健壮性。
- 同时也可减少开发/测试回归侧的人力投入,避免因此类问题导致的线上事故,保障项目高效、高质量发布。
没有完备的单元测试的代码所构成的一个系统,就像组装一架飞机,各个配件没有分别经过严格检测,只在最后组装好后,再通过试飞来检验飞机是否正常一样
-
单元测试类型
快照测试(Snapshot)
快照测试是第一次运行测试的时候在不同情况下的渲染结果(挂载前)保存的一份快照文件,后面每次再运行快照测试时,都会和第一次的比较。
快照测试主要用于检查特定状态下,组件渲染是否匹配对应的 dom 结构,如果不符合则认为该测试用例不通过,一般应用于 UI 组件 测试。
// 如下就是一个组件的快照
exports[`<JestTest/> Snapshot 1`] = `
<Text
accessible={true}
allowFontScaling={true}
ellipsizeMode="tail"
onClick={[Function]}
>
You not liked this.Click to toggle.
</Text>
`;
功能测试
单元测试具有独立的运行环境,可以方便快速的独立 mock 某一模块的各种逻辑,在单测中,可以通过代码覆盖率快速定位到具体哪个函数、哪一行没有覆盖到,从而避免出现某些逻辑被忽视。
功能测试主要测试组件生成的 DOM 节点是否符合预期,比如响应事件(点击/输入/异步请求)之后,组件的属性与状态是否符合预期。需要注意的是,这些操作需要依赖第三方库实现。
同时也可以对一些公共 Function
进行测试用例编写,这相对简单很多,在此不做过多赘述。
代码可测性
单元测试,一般会测试某个文件、某个模块、某个函数、甚至是某一句代码,这个就对代码的模块化和独立性要求比较高。一般单元测试比较全面的代码,可维护性也会较高。
-
技术选型
ReactNative 主流单元测试框架
框架组合 | 厂商 | 特点 | RN 支持度 | RN 官方使用 |
---|---|---|---|---|
Jest+Enzyme | airbnb | 使用类似JQ的语法方式对结果进行断言(对 ReactNative 单测支持不是很友好,如使用 mount 时会编译报错等) | 一般 | 否 |
Jest+react-test-renderer | 将 React 组件渲染为纯 JavaScript 对象,而不依赖于 DOM 或本机移动环境。 | 友好 | 是 |
二次封装库对比
以下是基于 Jest+react-test-renderer 方案,界普遍使用 的 RN 三方库对比
库选择 | Star / 下载量(周) | 优点 | 缺点 | 文档 |
---|---|---|---|---|
@testing-library/jest-native | 300 / 151,525 | - 基于react-test-render,给组件增加自定义属性(testID),便于快速识别校对 |
- 着重于属性、内容的渲染校验比对 | 没有交互类事件,无法处理强交互组件 mock 模型 | api文档完善,样例完整 | | @testing-library/react-native | 2.4k / 235,921 | - 基于react-test-render
- 可以引入jest-native拓展
- 通过API 获取dom及其children,用于快速识别校对
- 增加 fileEvent 事件,增加交互校对 | jest dom 获取没有前者方便 | 有demo项目,丰富的样例素材,方便上手demo | | react-test-renderer | 5,048,372 | 官网推荐 | | |
结论
推荐使用 react-test-render
+ @testing-library/react-native
,基于以下四点原因:
- 更符合我们的需求
- 支持交互类事件的用例
- 文档齐全,样例丰富
- 社区活跃度高
另外,针对 React Hooks 的方法,推荐使用 @testing-library/react-hooks
库辅助测试
yarn add -D @testing-library/react-hooks
-
单元测试接入实践
前端组件化已经让 UI 测试变得容易很多,每个组件都可以被简化为这样一个表达式,即 UI = f(data),这个纯函数返回的只是一个描述 UI 组件应该是什么样子的虚拟 DOM,本质上就是一个树形的数据结构。给这个纯函数输入一些应用程序的状态,就会得到相应的 UI 描述的输出,这个过程不会去直接操作实际的 UI 元素,也不会产生所谓的副作用。
无法复制加载中的内容
当前业务中,APP
内部模块之间耦合度较低,大致可以分为三类,公共基础/业务组件、公共方法、公共 Hook,他们的组合覆盖了APP
70%+ 的功能,那我们只用基于这些组件单元进行编写测试用例即可覆盖 APP 70% 以上的代码。
# 公共方法 - 使用功能测试
/src/common/**
# 公共hooks - 使用功能测试/快照(涉及dom时)
/src/hooks/**
# Redux 函数
/src/store/**
# 公共基础组件 - 快照
/src/components/**
# 公共业务组件 - 快照
/src/modules/**
下面我们以APP
内部的几个组件、功能方法以及 Hook 为例,演示测试用例编写
UI 组件测试用例编写
快照测试
import React from 'react'
import renderer from 'react-test-renderer'
import { render, fireEvent } from '@testing-library/react-native'
import AsButton from '~/components/as-button/index'
import colors from '~/common/colors'
// 快照测试
it('renders as button with snapshot', () => {
const Button = renderer.create(
<AsButton
key="default"
containerStyle={{ width: 140 }}
textStyle={{ color: colors.content }}
onPress={() => console.log('AsButton onPress')}
>
按钮
</AsButton>
)
const tree = Button.toJSON()
expect(tree).toMatchSnapshot()
})
如果该组件逻辑已变更,但是没有更新快照,则检查时会产生报错(如下图);
首先需要校对这个修改是否符合预期
- 如果需要更新快照,执行
npm t -- --testPathPattern={path}
; - 如果不符合,检查并还原代码;
组件 事件回调 测试用例
// component function callback test
import React from 'react'
import renderer from 'react-test-renderer'
import { render, fireEvent } from '@testing-library/react-native'
import AsButton from '../index'
import colors from '~/common/colors'
it('test as button onPress action', () => {
const passport = '发财密码:123456'
let submittedData = ''
// mock回调方法
const handlePress = jest.fn(() => (submittedData = passport))
const { getByText } = render(
<AsButton
key="default"
containerStyle={{ width: 140 }}
textStyle={{ color: colors.content }}
onPress={handlePress}
>
点我发财
</AsButton>
)
// 通过text寻找dom,模拟点击
fireEvent.press(getByText('点我发财'))
// 断言结果是否一致
expect(submittedData).toEqual(passport)
})
一般方法测试用例编写
import renderer from 'react-test-renderer'
import moment from 'moment'
import { calcLastTime, formatMsgTime } from '~/common/date'
describe('测试 date.js ', () => {
// 该方法返回的是 ReactDom,选择快照测试
test('测试 calcLastTime 方法计算剩余时间快照', () => {
const RenderMinute = renderer.create(calcLastTime(50))
const RenderHour = renderer.create(calcLastTime(20 * 60))
const RenderDate = renderer.create(calcLastTime(50 * 60))
const RenderOverTime = renderer.create(calcLastTime(20, true))
// 快照
expect(RenderMinute.toJSON()).toMatchSnapshot()
expect(RenderHour.toJSON()).toMatchSnapshot()
expect(RenderDate.toJSON()).toMatchSnapshot()
expect(RenderOverTime.toJSON()).toMatchSnapshot()
})
// 该方法返回的是 String,选择功能测试
const formatDate = moment().format('YYYY-MM-DD')
test('测试 formatMsgTime 函数', () => {
const date = moment(formatDate).subtract(-7, 'hours')
expect(formatMsgTime(date)).toBe('07:00')
expect(formatMsgTime(date.subtract(1, 'days'))).toBe('昨天 07:00')
expect(formatMsgTime(date.subtract(10, 'days'))).toBe(moment(date).format('MM月DD日 HH:mm'))
})
})
React hooks 单测
// ~common/hooks.js
// useSetInterval 方法
export const useSetInterval = (func, interval) => {
const timerRef = useRef(0)
const [time, setTime] = useState(0)
useEffect(() => {
timerRef.current = setTimeout(() => {
func()
setTime(time + 1)
}, interval)
return () => {
timerRef.current && clearTimeout(timerRef.current)
}
}, [time])
}
// __test__/hook.test.js
// useSetInterval 单测
import { renderHook, act } from '@testing-library/react-hooks'
import { useSetInterval } from '~/common/hooks'
jest.useFakeTimers()
jest.spyOn(global, 'setTimeout')
describe('测试 date.js', () => {
let currentIndex = 0
test('测试 useSetInterval hook用例', async () => {
act(() => {
// 执行Hooks
renderHook(() =>
useSetInterval(() => {
currentIndex++
}, 1000)
)
})
jest.advanceTimersByTime(1500)
expect(currentIndex).toBe(1)
jest.advanceTimersByTime(1000)
expect(currentIndex).toBe(2)
jest.advanceTimersByTime(1000)
expect(currentIndex).toBe(3)
})
})
计划:可执行单测模块测试覆盖率 90% 以上,核心模块覆盖率 100%
单测接入开发流程
我们不得不承认,编写单元测试就像 eslint 这种控制代码质量的工具一样,如果不强制绑定到开发流程中,那么它必将慢慢的被大家遗忘。为了确保团队单测质量,我们需要把它们加入到 Git
工作流中来。(需要团队搭建CI/CD流程)
流程如下图如图所示
暂时无法在文档外展示此内容
我们将单元测试的运行集中于两个点,git commit
与 Merge Request
中
git commit 流程
在开发者开发过程中,我们会在git commit 时,检查当前提交的代码中是否包含已有单元测试相关文件,如果不存在则跳过单测部分,节省运行时间;如果存在,则会在 pre-commit-hook 中找到已有单测且被修改的文件,对它们执行的单测用例。如果单测不通过则不允许代码提交,从源头保障了代码提交的合规性。其次,我们不进行全量单测扫描,只对增量单测相关文件进行检查,检查粒度更细,有效节省了编译时间,避免开发时间浪费。
// package.json
"husky": {
"hooks": {
"pre-commit": "npm run unit-test"
}
}
// unit-test 脚本
const fs = require('fs')
const path = require('path')
const shell = require('shelljs')
const jestDir = path.join(__dirname, '..', '__test__')
// 获取单元测试文件列表
const getUnitJestFileNameList = (dir, list = []) => {
const files = fs.readdirSync(dir)
files.forEach((file) => {
const fullPath = path.join(dir, file)
const stat = fs.statSync(fullPath)
if (stat.isDirectory()) {
if (!fullPath.endsWith('__snapshots__')) {
getUnitJestFileNameList(path.join(dir, file), list) // 递归读取文件
}
} else {
const name = file.replace('.test.js', '')
list.push({ name, fullPath })
}
})
}
// 获取到当前 git 修改文件列表
const getModifyGitFiles = () => {
const gitLogs = shell.exec('git status').stdout.toString().trim().split('\n') || []
return gitLogs.filter((log) => log.startsWith('\tnew file:') || log.startsWith('\tmodified:'))
}
// 检查当前待提交文件中包含测试用例的文件,并执行单元测试运行
const getChangedJestFile = () => {
const jestFileList = []
getUnitJestFileNameList(jestDir, jestFileList)
const gitStatusLogs = getModifyGitFiles()
jestFileList.forEach(({ name, fullPath }) => {
const isFileChanged = gitStatusLogs.some((log) => log.includes(name))
if (isFileChanged) {
console.log('即将运行的单元测试文件', name)
const result = shell.exec(`jest ${fullPath}`).stderr.toString().trim()
/**
* 退出代码(阻止 git commit 成功)
* 返回 1 表示错误
* 返回 0 表示通过检测
*/
if (result.includes('failed,')) {
console.error(
'错误信息:模块【',
name,
'】, 路径:【',
fullPath,
'】单元测试不通过,请先检查测试用例是否是最新的'
)
process.exit(1)
}
}
})
}
getChangedJestFile()
Merge Request CI
在开发分支往主分支合并的过程中,我们通过CI 注入 unit-test-job
,执行全量单测用例扫描,确保当前MR单测用例的合规性。
# gitlab-ci.yml
unit_test:
stage: scan
script:
- npm install
- npm run test
only:
- merge_requests
我们希望能尽早介入单测的质量评估,以确保项目健康运行,避免把这些问题都带到上线前解决;
-
问题
6.1 在各个 hook
编写单元测试时,发现一些 hook
非常难以测试,大体的特征如下:
hook
的实现非常复杂,状态繁多,依赖繁多hook
的实现不复杂,但外部依赖难以mock
hook
的实现自成一体,没有入口
涉及到一些复杂的Hook实现,我建议将它的测试用例放到集成测试阶段进行实现,而不要花费过多精力在编写单元测试的
mock
逻辑上。
6.2 无法模拟 Keyboard Event、Blur Event 以及一些复杂移动端操作,会导致部分模块单测覆盖率无法达标 6.3 无法针对业务场景进行 mock
参考文档
转载自:https://juejin.cn/post/7145432321938685965