技术选型
测试工具选型:Jest + testing-library
jest是一个开源的javascript单元测试框架,集成了测试执行器、断言库、spy、mock、snapshot和测试覆盖率报告等功能。@testing-library是用于Dom和UI组件测试的工具,提供了一系列常用的测试API。
注意:react-native-testing-library 已经转移到 @test-library/react-native
环境搭建
安装 Jest
npm i jest@27.0.2
npm i babel-jest@27.0.2 // babel进行转码
npm i ts-jest@27.0.2 // ts语法解析
npm i @types/jest // ts语法定义
说明
项目中如果使用的 TypeScript,需要安装 ts-jest、@types/jest,并在 tsconfig.json 文件中加入 "types": ["jest"]
注意:jest、ts-jest、babel-jest 三个依赖包的版本需要一致,否则可能会报错
安装 testing-library
npm i @testing-library/jest-dom @testing-library/react-native @testing-library/react-hooks -D
说明
@testing-library/jest-dom提供了一组可用于扩展 jest 的自定义 jest 匹配器。这些将使您的测试更具声明性,更易于阅读维护。@testing-library/react-native用于测试 React Native 组件。(如果测试React,请选择@testing-library/react)@testing-library/react-hooks为React Hook创建一个简单的测试工具,并在函数组件体内运行。可选择性下载该项。
package.json 文件配置
"scripts": {
...
"test": "jest test --verbose -u --watch",
}
"jest": {
"preset": "react-native"
}
babel 文件配置
- 如果项目中
bebel配置使用的是.babelrc.js,需要将文件转为babel.config.js(可能会影响项目原本编译结果) - 如果转化为
babel.config.js文件影响项目编译结果,可以保留.babelrc.js文件,然后新建babel.config.js文件并将presets声明移入babel.config.js
示例:
// babel.config.js
module.exports = (api) => {
api.cache.never();
return {
"presets": process.env.MINI_PROGRAM === 'true' ? [] : ['module:metro-react-native-babel-preset'],
}
};
babel 配置文件 .babelrc.js 与babel.config.js 之间的区别
jest.config.js 文件配置
项目根目录新建 jest.config.js,配置可参考如下(笔者项目使用的是 ts 的语法,js 等配置可自行添加)
const { defaults } = require('ts-jest/presets');
module.exports = {
...defaults,
preset: 'react-native',
globals: {
'ts-jest': {
babelConfig: true,
},
},
// 定义测试的文件目录,与配置文件同级目录下的 tests 文件夹内的 .tsx/.jsx 后缀名文件
testRegex: '(/tests/.*\\.(test|spec))\\.[tj]sx?$',
// 定义文件的编译方式
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
// 定义了忽略进行 jest 执行的依赖包
transformIgnorePatterns: [
'node_modules/(?!(react-native|@testing-library|react-navigation|@react-navigation/.*|@react-native-community)/)',
],
testPathIgnorePatterns: ['<rootDir>/node_modules/', '\\.snap$'],
// 缓存文件生成的目录地址,注意在.gitignore 中忽略
cacheDirectory: '.jest/cache',
testEnvironment: 'jsdom',
moduleNameMapper: {
'^[@./a-zA-Z0-9$_-]+\\.(png|gif)$': '<rootDir>/node_modules/react-native/Libraries/Image/RelativeImageStub',
},
};
更多config 说明查看文档最底部 jest.config.js
基础知识
简单例子
常见的一个测试文件如下:
// sum.test.ts
const sum = (a, b) => {
return a + b;
}
describe('Unit test explain', () => {
test('sum success', () => {
expect(sum(1, 2)).toBe(3);
})
})
describe通常将测试套件分解为组件。意思就是我们可以将某个组件的整体功能进行分解,按每个模块或者每个功能进行区分。而test/it是执行个别测试的地方,用来描述每一个测试功能点。test称为测试用例,接收两个参数。第一个参数是该用例的描述,第二个是测试函数,用来定义逻辑。与it同义。expect是期望的意思,整行称为断言。上面的意思就是期望1 + 2能否等于3。toBe是一个匹配器,匹配expect中预期的值和匹配器中的值是否相等。
常见的匹配器
toBe(value)是否完全相等,等同于===toBeNull(value)是否为nulltoBeUndefined()是否为undefinedtoBeNaN()匹配NaNtoBeTruthy()匹配truetoBeFalsy()匹配false.not后续匹配取反toMatch(regexpOrString)检查字符串是否匹配,可传字符串或者正则表达式toMatchObject(object)判断一个对象/数组是否属于子集toContain(item)匹配数组/Set/字符串中是否包含item- **
toContainEqual(item)匹配数组中是否包含一个特定对象 toHaveProperty(keyPath, value)匹配对象中深度嵌套的属性,判断在指定keyPath下是否有value属性toHaveLength(number)匹配对象length值toThrow(err)/toThrowError(err)匹配异常expect中传入函数才可以匹配到异常toBeCalled()/toHaveBeCalled()匹配函数是否被执行toReturn()/toHaveReturned()匹配函数是否有返回值toReturnWith(value)/toHaveReturnedWith(value)匹配函数返回值是否匹配
以上列举了常见的匹配器,更多信息可查看 预期 - 匹配器
Testing Library
我们再来看一个例子:
import React from 'react';
import "setimmediate"; // setimmediate 不要忘记引入
import { render } from '@testing-library/react-native';
import Button from '../lib/button';
describe('Button unit test', () => {
test('render success', () => {
const { getByTestId } = render(<Button testID='button' />);
// 期待找到 testID 为 button 的元素
expect(() => getByTestId('button')).not.toThrow(/Unable to find an element with testID/);
})
})
上述例子是为了测试 Button 组件是否渲染成功,并通过 testID 标识是否能找到相应的 Dom 元素。
render()用来对组件的渲染,透出的getByTestId方法用以接下来对testID的查找getByTestId()查找渲染的元素中,有无testID为btn的元素toThrow(err)匹配器须传入函数才可以匹配到异常,所以我们传入() => getByTestId('btn')并且预期不抛出无法根据testID找到节点的错误
常见查询器
先看概述:
getBy...返回查询的匹配节点,如果未查找到、或者查找到多个,则抛出一个描述性的错误。quertBy...返回查询的匹配节点,如果没有匹配的元素则返回null。如果匹配到多个,则抛出错误。findBy...返回一个Promise,该Promise在找到与匹配项时进行解析。如果未匹配到元素,或者超过匹配时间(默认1000ms)时,Promise将被拒绝。
同上,getAllBy... quertAllBy... findAllBy... 查询匹配条件的全部节点。
常用的查询器:
getByRole查询具有给定角色的元素。getByLabelText查询label与给定文本匹配的元素。getByPlaceholderText查询所有匹配占位符文本的元素。getByText查询所有文本节点与给定文本匹配的元素。getByTestId查询元素包含data-testid="${yourId}"的元素,在RN组件中,默认提供了testID属性。
更多查询器信息可查看 查询器
测试实例
快照测试
快照测试是第一次运行测试的时候,在不同情况下的渲染结果保存的一份快照文件,后面每次再运行快照测试时,都会和第一次的比较。
it("test snapshot", () => {
const component = render(<App />);
expect(component.toJSON()).toMatchSnapshot();
});
使用 render 渲染 App 组件,然后匹配 App 组件的片段与快照匹配。
这时候,如果修改 App.js,测试将会失败,因为快照将不再符合条件,可使用 npm test -- -u 更新快照。
测试元素
test('render success', () => {
const { getByText } = render(<Text>描述信息</Text>)
expect(getByText('描述信息')).not.toThrow(/Unable to find an element with text/);
})
说明
渲染一个 Text 文本,通过 getByText('描述信息') 来匹配渲染组件中有无符合 描述信息 的文本元素,如果不能匹配到,则抛出一个错误。
测试事件
// 模拟 onClose 事件
const onClose = jest.fn();
const { getByTestId } = render(<Button onPress={onClose} />);
act(() => {
// 执行 testID 为 button 元素的点击事件
fireEvent.press(getByTestId('button'));
})
// 点击 button 元素后 onClose 方法被调用达到预期效果,测试通过
expect(onClose).toBeCalled();
说明
上例中,我们需要关心的是 Button 组件的点击事件是否被正确调用,这时候需要使用 Mock 函数。
- 使用
jest.fn()定义一个模拟函数,接着将onClose传入示例Button组件的点击属性中,接下来是执行这个点击事件。 简单来说,这个fireEvent接收一个Dom节点,并模拟触发Dom事件,如:点击、内容更改、滑动等。 - 通过
onClose函数是否被执行(匹配器 toBeCalled)来判断是否完成对onClose方法的测试。
测试异步操作
// 模拟一个数据请求方法
const fetchData = (fn) => {
axios.get('https://api_url/')
.then(response => {
fn(response.data)
})
}
// 使用 done() 方法
// 测试用例会等到 done 方法执行才结束
test('unit test', (done) => {
fetchData((data) => {
expect(data).toMatchObject({
code: 200
})
done();
})
})
我们不能用常规的逻辑去测试异步操作,测试用例不会等到请求结束才执行。所以常规逻辑都会优先与异步操作前执行完。
数据请求返回的是 Promise 对象
// 数据请求方法
const fetchData = () => {
return axios.get('https://api_url/')
}
// 使用 done() 方法
test('unit test', (done) => {
fetchData().then(res => {
expect(res.data).toMatchObject({
code: 200
})
done();
})
})
// 或者直接使用 return 方法
test('unit test', () => {
return fetchData().then(res => {
expect(res.data).toMatchObject({
code: 200
})
})
})
测试Hook
注意:测试原则上,应避免对组件的 props 或 state 进行断言。
我们使用 @testing-library/react-hooks 来测试 hook,因为测试原则上是对 Dom 和 UI 以及交互事件对 Dom\UI 所带来的影响进行测试,所以我们实际无法拿到定义在组件内部的 props 或者 state。
// ...
import { renderHook } from '@testing-library/react-hooks';
// 构建更改数据的自定义 hook
const useVisibleAction = () => {
const [visible, setVisible] = useState(false);
const update = useCallback((value: boolean) => setVisible(value), []);
return {
update,
value: visible,
}
}
it('unit test', () => {
const { result } = renderHook(useVisibleAction);
const { getByTestId } = render(<Button testID='testID' onPress={value => result.current.update(value)} />);
act(() => {
fireEvent.press(getByTestId('testID'));
})
expect(result.current.value).toBeTruthy();
})
通过构建一个自定义 hook 函数,如代码中的 useVisibleAction,我们定义 update 来执行 setVisible 的方法,并最终将 { update, value: visible } 返回出去。
在 Button 组件中的 onPress 方法中,定义 update 执行,接着通过 fireEvent.press 调用 onPress 方法执行。
最终我们可以模拟 hook 的执行,并通过 result.current.value 获取到执行后的 visible 的值,进行断言,完成测试。
使用时注意点
测试文件
全局安装 jest 依赖
npm install -g jest-cli
测试单个文件
测试单独的文件,只需要执行 jest demo.test.tsx 命令即可
注意:执行命令文件匹配,如果执行 jest demo.test.tsx 会匹配所有包含 demo.test.tsx 结尾的测试文件。
如:执行 jest demo.test.tsx 命令,test-demo.test.tsx 、 testdemo.test.tsx 等测试文件均会执行 。
测试全部文件
通过 npm run test 命令来执行脚本对所有测试文件进行测试并监听
mock 模拟库
mock 模拟 react-native 中标签
// TouchableHighlight
jest.mock('react-native/Libraries/Components/Touchable/TouchableHighlight', () => 'TouchableHighlight');
// Text
jest.mock('react-native/Libraries/Text/Text', () => 'Text');
mock 模拟 @react-native-clipboard/clipboard 库中方法:setString()、getString()
jest.mock('@react-native-clipboard/clipboard', () => {
let string = undefined;
return {
setString: (text) => string = text,
getString: () => string,
};
});
jest.config.js
jest.config.js 更多配置选项说明查看 Configuring Jest
module.exports = {
// Jest 配置基础的预设
preset: 'react-native',
// 模块使用的文件扩展名数组
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
// 检测测试文件的模式(tests 目录下的 tsx 或 jsx 文件)
testRegex: '(/tests/.*\\.(test|spec))\\.[tj]sx?$',
// 从正则表达式到转换器的路径的映射, 转换器是提供同步功能以转换源文件的模块
transform: {
'^.+\\.tsx?$': 'ts-jest', // 转换 ts 模块文件
'\\.[jt]sx?$': 'babel-jest', // 转换 js 模块文件
},
// 允许传输 Jest 将无法理解的第三方模块未翻译转换的模块
transformIgnorePatterns: [
'node_modules/(?!(@package-name)/)',
],
// 匹配的路径下文件将跳过覆盖率信息
testPathIgnorePatterns: ['<rootDir>/node_modules/', '\\.snap$'],
// 存储其缓存的依赖项信息的目录
cacheDirectory: '.jest/cache',
// 设置 jsdom 使用类似浏览器的环境,默认环境是 Node.js 环境
testEnvironment: 'jsdom',
// 从正则表达式到模块名或模块名数组的映射
moduleNameMapper: {
'^[@./a-zA-Z0-9$_-]+\\.(png|gif)$': '<rootDir>/node_modules/react-native/Libraries/Image/RelativeImageStub',
},
// 测试中的所有模块是否自动模拟
automock: false,
// Jest运行所有测试,在 n 次失败后让 Jest 停止运行测试
bail: 1, // 和bail: true 等价
// 在每次测试之前是否清除模拟调用和实例
clearMocks: false,
// 在执行测试时是否应收集覆盖率信息,可能会显著降低测试速度
collectCoverage: false,
// 收集覆盖率信息的匹配文件,数组后面项会叠加覆盖前面匹配项,collectCoverage 为 true 时有效
collectCoverageFrom: [
"**/*.{js,jsx}",
"!**/node_modules/**",
"!**/vendor/**"
],
// 覆盖率文件的输出目录
coverageDirectory: 'coverage',
// 使用哪个提供程序为覆盖范围插入代码,默认:'label','v8' 目前处于实验性阶段
coverageProvider: 'label',
// 控制台输出的覆盖率报告信息摘要
moduleFileExtensions: ['json', 'lcov', 'text', 'clover'],
// 是否应在运行期间报告每个单独的测试,单个测试默认为 true
verbose: false,
}
...
参考
Jest 官方文档
testing-library (可能需翻墙才能访问)
如何使用 React Testing Library 和 Jest 测试 React 应用