在较大规模的前端项目中,测试对于保证代码质量十分重要,而 React 的组件化和函数式编程, 这种相同输入一定返回相同输出的幂等特性特别适合单元测试。本篇即是 React 和 React Native 项目单元测试的完整方案介绍。
一、技术选型: Jest + Enzyme + react-hooks-testing-library
1.1 jest
Jest 是 FaceBook 出品的前端测试框架,适合用于 React 和 React Native 的单元测试。
有以下几个特点:
- 简单易用:易配置,自带断言库和 mock 库。
- 快照测试:能够创造一个当前组件的渲染快照,通过和上次保存的快照进行比较,如果两者不匹配说明测试失败。
- 测试报告:内置了 Istanbul,通过一定配置可以测试代码覆盖率,生成测试报告。
1.2 Enzyme
Enzyme 是 AirBnb 开源的 React 测试工具库,通过一套简洁的 api,可以渲染一个或多个组件,查找元素,模拟元素交互(如点击,触摸),通过和 Jest 相互配合可以提供完整的 React 组件测试能力。
二、环境配置
直接贴上所需要安装的依赖:
复制代码
"devDependencies": {
"@testing-library/react-hooks": "^3.2.1", //React Hooks 测试支持,仅支持 React 16.9.0 以上
"babel-jest": "^24.8.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0", // 依据对应 React 版本安装,React 15 需安装 enzyme-adapter-react-15
"jest": "^24.8.0",
"jest-junit": "^7.0.0",
"jest-react-native": "^18.0.0", //RN 支持,非 RN 可以不装
"react-test-renderer": "16.9.0",
"redux-mock-store": "^1.5.3" //Redux 测试模拟 store
}
根目录下添加 jest.config.js 文件作为配置文件:
复制代码
module.exports = {
preset: 'react-native',
globals: { // 模拟的全局变量
_window: {},
__DEV__: true,
},
setupFiles: ['./jest.setup.js'], // 运行测试前需运行的初始化文件,例子在下方
moduleNameMapper: { // 需要模拟的静态资源
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
"\\.(css|less|scss)$": "<rootDir>/__mocks__/stylesMock.js"
},
transform: { // 转译配置,RN 项目配置如下,普通 React 项目可以使用 babel-jest
'^.+\\.js$': '<rootDir>/node_modules/react-native/jest/preprocessor.js',
},
testMatch: ['**/__tests__/**/*.(spec|test).js'],// 正则匹配的测试文件
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
unmockedModulePathPatterns: ['<rootDir>/node_modules/react'],
collectCoverage: true,
collectCoverageFrom: [// 生成测试报告时需覆盖测试的文件
'src/**/*.js',
],
coverageReporters: ['text-summary', 'json-summary', 'lcov', 'html', 'clover'],
testResultsProcessor: './node_modules/jest-junit',
transformIgnorePatterns: ['<rootDir>/node_modules/(?!@ctrip|react-native)'], //transform 白名单
};
三、Jest 简单函数单元测试
待测试函数
复制代码
function add(x, y) { return x + y;}
测试文件
复制代码
test('should return 3', () => {
const x = 1;
const y = 2;
const output = 3;
expect(add(x, y)).toBe(output);
});
});
- describe:创造一个块,将一组相关的测试用例组合在一起
- test:也可以用 it,测试用例
- expect:使用该函数断言某个值
常用断言
- toBe:测试是否完全相等
- toBeCloseTo:浮点数比较
- toEqual:对象深度比较
- not:取反
- toBeNull:匹配 null
- toBeUndefined:匹配 undefined
- toBeDefined:与 toBeUndefined 相反
- toBeTruthy:匹配真
- toBeFalsy:匹配假
- toBeGreaterThan:大于
- toBeGreaterThanOrEqual:大于等于
- toBeLessThan:小于
- toBeLessThanOrEqual:小于等于
- toMatch:正则表达匹配
- resolves/reject:测试 promise
- toBeCalled:函数是否被调用
- toBeCalledWith:函数是否以某些参数为入参被调用
- assertions:检测用例中有多少个断言被调用,一般用于异步测试
四、Jest 周期函数
在写测试用例之前,可以用四个周期函数进行一些处理:
复制代码
beforeAll(() => {
console.log('所有测试用例测试之前运行');
});
afterAll(() => {
console.log('所有测试用例测试完毕后运行');
});
beforeEach(() =>{
console.log('每个测试用例测试之前运行');
});
afterEach(() => {
console.log('每个测试用例测试完毕后运行');
});
五、Jest Mock 函数
在单元测试中,有许多对象或函数并不需要真实的引用,因此需要 mock。比如之前提到的初始化文件 jest.setup.js 中,我们会 mock 一些对象:
复制代码
jest.useFakeTimers(); //mock 时间
jest.mock('./src/commons/CViewPort', () => { //mock 一些组件
return props => {
return <View {...props}>{props && props.children}</View>;
};
});
jest.mock('./src/commons/CToast', () => {
return {
show: () => {},
};
});
也可以手动 mock 一些 React Native 组件,在根目录下建立 mocks 文件夹。文件下建立需要 mock 的组件的文件,如建立 InteractionManager.js。
复制代码
const InteractionManager = {
runAfterInteractions: callback => callback(),
};
module.exports = InteractionManager;
建立好文件后,这样 mock 即可:
复制代码
jest.mock('InteractionManager');
六、Jest UI 快照测试
Jest 提供了 snapshot 快照功能用于 UI 测试,可以创建组件的渲染快照并将其与以前保存的快照进行比较,如果两者不匹配,则测试失败。快照将在测试文件的当前文件路径自动生成的 snapshots 文件夹中保存。当主动修改造成 ui 变化时,使用 jest -u 来更新快照。
复制代码
it('render List', () => {
const tree = renderer.create(<List {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});
快照不匹配:
七、Jest 异步测试
Jest 单元测试是同步的,因此面对异步操作如 fetch 获取数据,需要进行异步的模拟测试。首先,对 fetch 函数进行 mock:
复制代码
const cityInfo = {
1: '北京',
2: '上海'
}
export default function fetch(url, params) {
return new Promise((resolve, reject) => {
if (params.cityId && cityInfo[params.cityId]) {
resolve(cityInfo[params.cityId]);
} else {
reject('city not found');
}
});
}
接着创建测试用例进行异步测试:
复制代码
it('test cityInfo', async () => {
expect.assertions(1); // 检测用例中有多少个断言被调用
const data = await fetch('/cityInfo', {cityId: 1});
expect(data).toEqual('北京');
});
八、Enzyme 组件测试
复制代码
import { mount, shallow, render } from ‘enzyme';
Enzyme 对测试组件进行渲染分为三种:
- shallow:浅渲染,仅渲染单个组件,不包括其子组件。这对于隔离组件进行纯单元测试很有用,效率高,可以进行模拟交互,并且从 Enzyme 3 开始也可以访问组件生命周期,所以一般组件测试用 shallow 即可。
- mount:完整渲染,包括其子组件。因为渲染了真实的 DOM 节点,可以用来测试 DOM API 的交互和组件的生命周期。
- render:静态渲染,渲染为静态 HTML 字符串,包括子组件,不能访问生命周期,不能模拟交互。
8.1 测试组件模拟交互
复制代码
const onClickLabel = jest.fn();
const label = shallow(<Label filterData={filterData} onClickLabel={onClickLabel} />);
label.childAt(0).find({ eventName: 'click filterLabel' }).simulate('press');
expect(onClickLabel).toBeCalled();
8.2 测试组件内部方法
复制代码
const fliterModal = shallow(<FilterModal {...props} />);
const instance = fliterModal.instance(); // 获取当前组件实例
//jest.spyOn 创建一个 mock 函数,该 mock 函数不仅捕获函数的调用情况,还可以正常的执行被 spy 的函数。
jest.spyOn(instance, '_onClear');
instance.forceUpdate();
fliterModal.childAt(0).simulate('press');
expect(instance._onClear).toBeCalled();// 测试组件实例上的方法是否被调用
九、Redux 测试
在使用 React 或者 React Native 时通常会使用 Redux 进行状态的管理,需要 mock store 进行测试。
复制代码
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { updateList } from '../pages/List/action';
const middlewares = [thunk];
// 引入 redux-mock-store 对 store 进行 mock
const mockStore = configureMockStore(middlewares);
describe('list action test', () => {
it('updateList test', () => {
const store = mockStore({ flist: {} });
const mockData = {
flist: { afitem: 1 }
};
const expectedActions = { type: 'UPDATE_LIST', flist: { afitem: 1 }};
expect(store.dispatch(updateList(mockData.flist))).toEqual(expectedActions);
});
});
十、React-Hooks 单元测试
在 React Native v0.59 版本以后,RN 也支持了 React Hooks 的开发,由于 Enzyme 对于 Hooks 的测试支持不理想,我们专门引入了 react-hooks-testing-library 用于 Hooks 的测试。
10.1 安装
复制代码
npm install --save-dev @testing-library/react-hooks
10.2 useState 测试
复制代码
// useCityName.js
import { useState, useCallback } from 'react';
export default function useCityName() {
const [cityName, setCityName] = useState('北京');
const format = useCallback(() => setCityName(x => x + '市'), []);
return { cityName, format };
}
// useCityName.test.js
describe('test useCityName', () => {
it('should use cityname', () => {
const { result } = renderHook(() => useCityName());
expect(result.current.cityName).toBe('北京');
expect(typeof result.current.format).toBe('function');
});
it('should format cityname', () => {
const { result } = renderHook(() => useCityName());
act(() => {
result.current.format();
});
expect(result.current.cityName).toBe('北京市');
});
});
10.3 useEffect 测试
复制代码
// useCityInfo.js
import { useEffect } from 'react';
export default function useCityInfo({ cityInfo, id }) {
useEffect(() => {
cityInfo[id] = '北京';
return () => {
cityInfo[id] = '上海';
};
}, [id]);
}
// useCityInfo.test.js
describe('test useCityName', () => {
it('should handle useEffect hook', () => {
const cityInfo = {
1: '北京',
2: '上海',
};
const { sideEffect, unmount } = renderHook(useCityInfo, { initialProps: { cityInfo, id: 1 } });
sideEffect({ cityInfo, id: 1 });
expect(cityInfo[1]).toBe('北京');
sideEffect({ cityInfo, id: 2 });
expect(cityInfo[2]).toBe('北京');
unmount();
expect(cityInfo[1]).toBe('上海');
expect(cityInfo[2]).toBe('上海');
});
});
十一、单元测试覆盖率及 husky 做代码提交检查
Jest 集成了 Istanbul 这个代码覆盖工具并会生成详细报告,执行 jest –coverage 即可生成基于四个维度的覆盖率报告:
- 语句覆盖率 (statement)
- 分支覆盖率 (branches)
- 函数覆盖率 (functions)
- 行覆盖率 (lines)
同时我们会配置 husky 在 commit 或者 push 之前添加钩子,在这些动作之前强制执行单元测试,通过测试才可提交到远程代码仓库以保证代码质量。
husky 在 package.json 中的配置:
复制代码
"scripts": {,
"test": "jest --forceExit --silent"
},
"devDependencies": {
"husky": "^3.0.9"
},
"husky": {
"hooks": {
"pre-push": "npm run test"
}
},
十二、总结
本篇是 React Native 项目单元测试的一个简单教程,在携程的持续集成流程中再接入 sonar, 可以查看完整的单元测试报告。
在携程租车前端单元测试的实践中,我们总结出几个要点:
- 将待测试的组件当成黑盒,不用考虑内部逻辑实现;
- UI 改动频繁,优先保证公用组件,工具函数,核心代码的单元测试;
- 模拟数据尽量真实;
- 多考虑边界条件情况;
通过单元测试,给项目带来了不少好处:
- 通过单元测试可以确保代码得到预期的结果,在测试环境中就发现 bug;
- 当修改依赖的组件时,能在测试中发现被影响组件的错误,这样可以支持我们更好的重构代码,有利于项目的长期迭代;
- 良好的单元测试就是一份最好的注释,同时迫使我们写易于测试的函数式代码;
另外我们在写单元测试的时候并不是堆砌覆盖率,而是需要保证功能细节的正确,覆盖率并不是最重要的,单元测试也不是银弹,我们也在结合诸如 airtest 自动化测试等其他测试和手段保证代码的质量。
作者介绍:
琨玮,携程高级前端开发工程师,从事 React Native/Web 前端的开发及维护工作,喜欢研究新技术。
作者:琨玮
链接:https://www.infoq.cn/article/AYS6fpGLU7jb9kiXHDkC
看完两件小事
如果你觉得这篇文章对你挺有启发,我想请你帮我两个小忙:
- 把这篇文章分享给你的朋友 / 交流群,让更多的人看到,一起进步,一起成长!
- 关注公众号 「画漫画的程序员」,公众号后台回复「资源」 免费领取我精心整理的前端进阶资源教程
本文著作权归作者所有,如若转载,请注明出处
转载请注明:文章转载自「 Js中文网 · 前端进阶资源教程 」https://www.javascriptc.com