一、先简单介绍目前前端测试的主要类型
- 单元测试
- 单元测试是对开发人员所编写的代码进行测试,主要是逻辑功能测试。
- 单元测试覆盖了代码块,确保它们在运行时没有问题。被测试的单元可以是函数、模块和类等。单元测试应该相互隔离并且彼此独立。对于给定的输入,用单元测试检查结果,通过尽早发现问题并避免退化,可以帮助你确保程序的每个部分都能按预期工作。
- 集成测试
- 即使你的所有单元测试都通过了,也只能代表每个部分可以正常工作。尽管如此,该程序仍可能失败。集成测试涵盖跨模块流程,其中各个模块在一起工作时进行组合和测试。多亏了它,你可以用一种方法来确保你的代码在整体上能够正常运行。
- 端到端测试(E2E)
- 与其他类型的测试相反,端到端测试始终在浏览器(或类似浏览器)环境中运行。它可能是打开的真正浏览器,并且在其中运行测试。它也可能是无头浏览器环境,即没有用户界面运行的浏览器。E2E 测试的重点是在我们正在运行的程序中模拟实际用户。它将模拟滚动,单击和键入之类的行为,并从实际用户的角度检查我们的程序是否运行良好。
二、三种测试类型里为什么首选单元测试?
- 单元测试保障独立业务逻辑的正确性。只有一个个零件正确,组合起来的页面逻辑才会正确。
- 我们可以对复杂的组合逻辑(如页面,多个组件关联逻辑)做单元测试(即可看作使用简单版的集成测试)。E2E测试目前由测试人员编写,故前端开发主要以单元测试为主。
三、我们为什么在业务项目里重度使用单元测试呢?
业务需求迭代频繁,提测前都要人工自测的话,其耗费的时间可想而知。 能用工具解决的问题,为啥不用工具处理呢?尽管工具不能100%覆盖(如机型样式,需求文档问题),但也能节省很多人工时间,保证代码质量。 前期投入时,很多都要摸索,但后面轻车熟路后,对开发迭代增加的工作量(比修复bug耗费的时间划算)和代码质量相比还是值得的。
四、落地过程
1、解决诉求
- 产品业务逻辑复杂,开关配置项多,关联逻辑多,如果需求变更考虑不全,将会引发其它逻辑问题,尤其是多个开关逻辑条件下的特殊场景问题。
- 未使用单元测试时,上线前做三遍详细代码review,耗时长;且即使是对业务代码很熟的review人员,都可能发现不了隐藏的细节问题。
- 产品要求上线不能有功能性问题、用户使用时间短的特性,容不下我们在客户使用过程中解决线上问题,等分析出问题后,基本上使用过程就结束了。
- 重构、升级给测试人员带来不少压力,很多业务逻辑都要测试,测试中也会遗漏个别场景,出现问题(之前真实碰到过)。
2、过程(带来价值的前提条件)
- 基于jq迁移react的项目背景,因没有用独立迭代重构,考虑测试人员的测试压力,在项目过程中引入单元测试的。单元测试完善目的是为后期规划的代码重构、升级等工作做铺垫。
- 确保业务逻辑都按照独立功能逻辑抽离独立组件。
- 项目所有组件和公共方法逻辑都编写测试用例。
- 业务逻辑和业务场景需要理解正确,错误的理解或遗漏场景,即使单元测试做全,也是徒劳的。
- 项目所有组件和公共方法逻辑覆盖率整体强制
控制在90%以上
,实际测试文件的覆盖率达到95%以上。- 2020.10开始接入单元测试,2021年覆盖率达到80%,2022年2月覆盖率达到90%
- 提交代码和CI流水做覆盖检测,当测试用例错误或覆盖率小于90%时不能编译。
- 新功能逻辑和原逻辑变更都要求全面编写或修改单元测试,保证业务逻辑场景全覆盖。
3、价值
- 在项目初期开发时,单个组件可以通过编写单元测试即时调试,不必等页面框架代码完成才能调试。
- 在调试过程中,对于一些极限值,不必要求服务来 Mock 或者增加侵入式调试代码,这也在一定程度上提高了开发效率。
- 第三方库跨版本升级,减轻测试人员的压力。
- 如:react16升级18,antd-mobile 2升级5,umi3升级4,测试人员只要简单做部分页面点击测试即可
- 业务代码逻辑重构可以在需求迭代里进行,解决开发人员不敢对老代码进行重构的担忧。在单元测试保证业务逻辑的正确性下,不再要求测试人员详细测试了,测试人员只需要对部分页面点击测试。
- 版本升级和重构逻辑都在普通迭代里进行,不影响测试进度(点击测试最多花几个小时)
- 测试环境基本没有业务逻辑bug问题,主要是样式、需求细节问题以及后台接口问题的bug;测试人员测试重点向无法或没有做单元测试的界面逻辑倾斜。
- 原先代码逻辑review耗时问题解决,功能开发完即可提测,不需要花费自测和review代码的时间。
- 上线环境稳定,不必担心代码逻辑问题,目前线上还没有发现前端业务逻辑问题。
- 线上问题排查不再困难,大部分都可以推断出是接口数据问题、样式机型问题等,基本不存在已知的前端业务逻辑问题。
4、新旧对比,效率提升
- 单元测试加入后,弱化代码review,大量节省review时间又保证产品质量。
- 业务逻辑复杂的需求逻辑,迭代开发更能保证之前的逻辑不受影响。
- 重构、升级大大减轻测试人员的测试工作量,提高效率。
- 需求开发后即可提测,不再浪费耗时的开发自测。
- 目前产品的H5端、复杂页面端做了单元测试覆盖,每次提交代码和构建都跑单元测试,不通过的一律不能构建代码,不能发布。
- xxH5端,测试套件
93
个,测试分组259
个,业务逻辑覆盖率95%
以上 - xx复杂页面重构时,加入单元测试;电视墙预览页面里,测试套件
9
个,测试分组38
个,组件覆盖率100%
,上线至今还没有反馈线上问题 - 预约管理微信端今年也开始加入单元测试
- xxH5端,测试套件
- 之前无单元测试时,
review代码3遍
,对于1个标准5人天以内的需求,自测时间0.5-1天
;且花了时间
,还不一定能发现逻辑问题,测试定位bug还需要另外花时间确定前端还是后端问题。
加入单元测试后,review以及自测代码时间可忽略不计
(极少迭代特殊逻辑做代码reivew),节省时间做优化事项;因此迭代大部分都是数据接口问题,测试bug定位也比之前快很多,且后面迭代需求,如果有改动影响原逻辑,单元测试就可以提早发现问题。 - 在开发阶段,可以通过单元测试发现逻辑分支细节问题。尤其是xx复杂页面重构时,因多人开发,测试用例编写发现不少问题,不过后面提测阶段基本没有逻辑问题了。提测时出现了网络慢导致的逻辑问题,这个是因为单元测试没做网络模拟测试导致的。
5、待优化点
- 机型样式测试无法实现,需要测试人员手动测试。
- 样式布局和PSD设计稿无法使用单元测试对比,目前也是依靠人工测试。
- 需求文档逻辑的遗漏问题,目前没有好的解决方案,还是要靠集体的力量,但还是会存在团队成员都没发现的问题。
五、业务团队使用上的未来规划
- 产品后台核心重要逻辑功能加入单元测试。
- 配合测试人员,完善E2E自动化测试。
- 其它项目根据项目价值规划加入单元测试(如:公共H5业务逻辑项目)。
六、单元测试搭建过程及使用
以下内容根据react18项目,jest29举例
1、react项目里jest相关开发依赖安装
- 使用@babel包的原因是,我们可以使用ES6的语法特性进行单元测试,ES6提供的 import 来导入模块的方式,Jest本身是不支持的
"devDependencies": {
"@babel/preset-env": "^7.19.0",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.0.2",
"babel-jest": "^29.0.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.0.3",
"jest-environment-jsdom": "^29.0.3",
"typescript": "^4.8.3"
},
2、jest配置文件配置jest.config.ts
module.exports = {
testEnvironmentOptions: {
url: 'http://localhost:8001/demo/',
},
collectCoverage: true,
testEnvironment: 'jsdom',
snapshotFormat: {
escapeString: true,
printBasicPrototype: true,
},
moduleNameMapper: {
'\\.(css|less|sass|scss|stylus)$': require.resolve('identity-obj-proxy'),
'^@/(.*)$': '<rootDir>/src/$1',
'^@@/(.*)$': '<rootDir>/src/.umi/$1',
},
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
testMatch: ['**/?*.(spec|test|e2e).(j|t)s?(x)'],
testPathIgnorePatterns: ['/node_modules/'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': require.resolve(
'./script/jest/transformers/javascript',
),
'^.+\\.(css|less|sass|scss|stylus)$': require.resolve(
'./script/jest/transformers/css',
),
'^(?!.*\\.(js|jsx|ts|tsx|css|less|sass|scss|stylus|json)$)':
require.resolve('./script/jest/transformers/file'),
},
verbose: true,
transformIgnorePatterns: ['/node_modules/(?!(antd-mobile)/)'],
setupFilesAfterEnv: [
'@testing-library/jest-dom/extend-expect',
'./__test__/jest.setup.tsx',
],
collectCoverageFrom: [
'src/service/request/*.{js,jsx,ts,tsx}',
'src/components/**/*.{js,jsx,ts,tsx}',
'src/pages/**/components/**/*.{js,jsx,ts,tsx}',
'src/hooks/**/*.{js,jsx,ts,tsx}',
// 页面文件
'src/pages/(favorite)/**/index.{js,jsx,ts,tsx}',
],
coverageThreshold: {
global: {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
};
- 里面需要主要关注的几个字段
- moduleNameMapper:模块映射里css等文件需要Mock掉,单元测试需要忽略才走得通
- testMatch:测试文件匹配规则,如a.test.tsx ,以.test.tsx结尾的作为测试文件
- transform:CSS,JS文件需要转换处理
- setupFilesAfterEnv: 这里是处理全局测试文件需要的资源,如对第三方库做通用Mock处理
- collectCoverageFrom:文件覆盖率目录设置,可以对react组件、工具函数、页面等做代码覆盖率配置。
- coverageThreshold:覆盖率强制要求配置,可以在提交代码或ci构建时跑单元测试,覆盖率没达到要求不能提交或构建代码
- transform 配置里相关文件如下(处理转化css,ts,svg等文件)
// css.js
module.exports = {
process() {
return { code: 'module.exports = {};' };
},
getCacheKey() {
// The output is always the same.
return 'css';
},
};
//javascript.js
const babelJest = require('babel-jest');
module.exports = babelJest.default.createTransformer({
presets: [
[
'@babel/preset-env',
{
targets: { node: 'current' },
modules: 'commonjs',
},
],
[
'@babel/preset-react',
{
runtime: 'automatic',
},
],
[
'@babel/preset-typescript',
{
allowNamespaces: true,
},
],
],
babelrc: false,
configFile: false,
});
//file.js
const path = require('path');
module.exports = {
process(src, filename) {
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
return {
code: `module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: ({ svgRef, ...props }) => ({
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: svgRef || null,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
}),
};`,
};
}
return { code: `module.exports = ${assetFilename};` };
},
};
3、配置好后,控制台运行测试用例即可
// 执行全部测试用例
yarn jest
// 执行一个测试用例
yarn jest xxx/xxx.test.ts
4、测试文件的编写(主要困难在于Mock和异步逻辑,需要多写多积累经验)
- Mock逻辑的编写,如动画,时间,第三方库等的处理
- 异步逻辑的处理
- 业务逻辑分支都要全面测试
- 单元测试全面相当于开发自己做了一遍完整的自测过程
- 参考相关文档
5、测试用例使用简单介绍
- 常用的判断类型:toBe (值类型)、toEqual (引用类型)、toBeNull、toBeDefined、toBeTruthy (true)、toBeFalsy (false)、toBeCloseTo (约等于)、toMatch (匹配包含)、toThrow、not 修饰符( not.toBe )、toBeGreaterThan(大于)、toBeGreaterThanOrEqual(大于或者等于)、toBeLessThan(小于)、toBeLessThanOrEqual(小于或等于)、toContain(包含)、toThrow(异常)
// 先编写一个需要测试的函数
function sum(a, b){
return a + b;
}
module.exports = sum
//创建一个sum.test.js文件,并写入我们的测试用例。
const sum = require('./sum');
test('adds 1 + 2 to equal 3',()=>{
// 精确匹配等于3
expect(sum(1,2)).toBe(3);
});
- 多次测试前后准备的钩子函数(beforeEach 和 afterEach)
- 测试用例多而复杂时,可以用describe进行分组
import { isCity, otherMethod } from '@/utils/isCity';
describe('测试 isCity', () => {
beforeEach(() => {
// 每个测试前初始化城市数据
initializeCityData();
});
afterEach(() => {
// 每个测试后清除城市数据
clearCityData();
});
test('city has BeiJing', () => {
expect(isCity('BeiJing')).toBeTruthy();
});
test('city has ShenZhen', () => {
expect(isCity('ShenZhen')).toBeTruthy();
});
});
describe('测试 otherMethod', () => {
。。。。。。其它方法测试,如测试otherMethod
});
- 异步测试:回调函数
// 需要测试的方法
const fetchUser = (cb) => {
setTimeout(() => {
cb('hello');
}, 100);
};
// 测试用例
// 使用done,done表示执行done函数后,测试结束。如果没有done,同步代码执行完后,测试就执行完了,测试不会等待异步代码。
test('test callback', (done) => {
fetchUser((data) => {
expect(data).toBe('hello');
done();
});
});
- 异步测试:promise(async/await)
// 需要测试的方法
const userPromise = () => Promise.resolve('hello')
// 测试用例
// promise方式
test('test promise', () => {
// 必须要用return返回出去,否则测试会提早结束,也不会进入到异步代码里面进行测试
return userPromise().then(data => {
expect(data).toBe('hello');
});
});
// async方式
test('test async', async () => {
const data = await userPromise();
expect(data).toBe('hello');
});
// Jest 框架提供了一种简化的写法,即 expect 的resolves和rejects表示返回的结果
test('test with resolve', () => {
return expect(userPromise()).resolves.toBe('hello');
});
- Mock Timer(定时器,基础场景)
// 需要测试的函数(文件timer-fun.ts)
const timerFun = (callback?:()=>void) => {
console.log('timerFun 调用');
setTimeout(() => {
console.log('1s 后调用');
callback && callback();
}, 1000);
};
export { timerFun };
// 测试用例(例子使用定义时间来控制程序运行)
import { timerFun } from './timer-fun';
import { act } from '@testing-library/react';
describe('timerFun 测试', () => {
beforeEach(() => {
jest.clearAllTimers();
});
test('timerFun', () => {
jest.useFakeTimers();
const callback = jest.fn();
timerFun(callback);
expect(callback).not.toHaveBeenCalled();
// mock 1s时间
act(() => {
jest.advanceTimersByTime(1000);
});
// 验证callback被调用1次
expect(callback).toHaveBeenCalledTimes(1);
// 清除调用次数
callback.mockClear();
// 再mock 1s时间
act(() => {
jest.advanceTimersByTime(1000);
});
// 验证这1s实际上没有调用callback,即,调用方法timerFun,只在1s后执行一次回调
expect(callback).not.toHaveBeenCalled();
});
});
- Mock 第三方库
// 如react组件里使用了qrcode.react库,我们可以对qrcode.react的mock,可以对入参prop断言是否传对
jest.mock('qrcode.react', () => {
return (prop: any) => {
return <div>qrcode.react components,value:{prop.value}</div>;
};
});
// 后面再测试我们组件自己的业务逻辑
- 对于dom的一些断言,可以添加testing-library库提供的jest-dom/extend-expect来更好地对dom进行断言
import {render, fireEvent, screen} from '@testing-library/react'
import '@testing-library/jest-dom'
... 省略部分代码
// 如:判断按钮是否禁用
expect(screen.getByRole('button')).toBeDisabled();
- 事件:FireEvent,实际的用户交互可以通过 react-testing-library 的 fireEvent 函数去模拟。
- 常用 fireEvent:键盘(keyDown,keyPress,keyUp),聚焦(focus,blur),表单(change,input,invalid,submit,reset),鼠标(click,dblClick,drag)
- 具体使用可看react-testing-library例子
import {render, fireEvent, screen} from '@testing-library/react'
... 省略部分代码
// 触发事件
fireEvent.click(screen.getByText('按钮'))
- 快照测试
- 快照测试是用于确保某个组件的UI不会有意外的改变。与UI测试不同,快照测试不会对比样式文件,仅对比dom结构和节点参数
- 不要在组件测试里过度使用。因为组件更改后,需要人工对比差异是否正确,人为因素比较重要,不然测试就无意义了
- 建议对常量配置项进行快照测试,毕竟它改动不频繁,人工对比差异误判率也很低
- 在代码更改后,确保人工对比差异无误后,使用jest xxx/xxx.test.ts -u 来更新样本
// 如:测试config对象
test('测试 config', () => {
const config = {
a: 'a',
b: 'b',
};
expect(config).toMatchSnapshot();
});
// 生成的快照文件,迁入版本管理
// 快照文件会放在测试文件同级目录下的 snapshots 文件夹中
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`测试 config 1`] = `
Object {
"a": "a",
"b": "b",
}
`;
七、总结
- 测试在整个需求开发的流程中起着重要作用,它对于需求的质量提供了强而有力的保障。
- 但是在实际的工作中,由于产品的迭代、需求的变更以及各种不确定的因素,我们经常会陷入“bug的轮回” —— 解决上一个bug,点亮另一个bug。单元测试用例的编写过程,也是开发人员自己发现bug、解决bug的过程。让提测代码更健壮,避免后期修复业务逻辑bug的返工浪费。
- 随着业务复杂度的提升,测试的人力成本也会越来越高,尤其是重构代码给测试人员带来的压力可想而知。面对这些痛点,作为前端开发,我也常常在思考有什么方法可以在解放双手的同时,又能保证产品的质量,不必每次在需求上线时紧张兮兮地盯着,生怕发的版本影响了其他的功能。借助单元测试的力量,这些痛点将会逐个击破,让测试人员的测试重点向机型样式兼容方面倾斜,重构逻辑或升级库都不再需要测试人员详细测试,只要简单少量的点击测试即可。