单元测试总结
备案系统前端 单元测试总结
背景:
备案系统,逻辑复杂,表单数据繁多,测试不充分,导致线上问题较多。
出现问题较多的大致分类:
- 测试不充分,边缘情况(线上特殊例子)没考虑到,例如:空值判断、字段校验中的正则表达式覆盖不全面(比如 域名后缀)等
- 备案类型多,功能相似,逻辑相同点有多处,修改处容易遗漏,例如:不同备案类型的表单都有提交、保存、新旧表单值比较等方法。
- 关联功能多(比如多处逻辑公用组件),也会存在拆东墙补西墙 等......
规避大部分线上问题思路:
- 关于 边缘情况的特殊例子通过jest单元测试用例,补充所有测试场景来规避。
- 业务逻辑中使用ts重构,规范定义 变量/函数类型,以及props中参数类型等。
- 类型多,功能相似,将表单的提交/保存方法的主要逻辑部分抽取出,写测试用例测试。同时开发过程中,写好注释。
具体方案 ts + jest + enzyme:
- 公共函数部分: jest
- 公共组件部分: enzyme(部分组件的写法不适合测试,需要优化)
- 具体表单逻辑部分使用ts重构,增强类型校验,减少问题的产生
- 根据测试报告,来补充测试用例,提高测试场景的覆盖率
执行步骤:
配置测试环境 --------> 锁定测试范围 ------> 模拟测试数据 -----------> 编写测试用例 --------> 核实测试覆盖率
- 配置jest必备的执行环境
- 查找文件的根路径
- 不转译的模块
- 模块别名
- 路径别名
- 需要babel编译的文件
- SetUpFile全局变量配置 和 引入enzyme
- 测试覆盖率配置
- 生成测试报告
var path = require('path');
var SRC_ROOT = path.join(__dirname, 'src/');
var ROOT = path.join(__dirname, '/');
var conf = {
// 多于一个测试文件运行时, 是否展示每个测试用例测试通过情况
verbose: true,
globals: { CONSOLE_ENV: 'pre' },
rootDir: ROOT,
// 以 SRC_ROOT 这个目录做为根目录来搜索测试文件(模块)
roots:[SRC_ROOT],
modulePaths: ['<rootDir>/src'],
//node_modules都被忽略, 不需要被转译
transformIgnorePatterns:['<rootDir>/node_modules/'],
// 在测试环境准备好之后且每个测试文件执行之前运行下述文件
setupFilesAfterEnv: ['<rootDir>/src/test/jest.setup.js'],
transform: {
"^.+\\.[t|j]sx?$": "babel-jest"
},
// 例如,require('uc_components') 会解析成 require('@ucloud/uc-components')
moduleNameMapper: {
"uc_components": "@ucloud/uc-components",
"react_components": "@ucloud-fe/react-components",
"libs/Yhwach": "<rootDir>/src/lib/Yhwach"
},
// 例如,require('./a') 语句会先找 `a.js`,找不到找 `a.jsx`
moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node'],
// 收集这些文件的测试覆盖率
collectCoverageFrom: [
'src/components/**/*.{js,jsx}',
'src/models/*.{js,jsx}',
'src/utils/*.{js,jsx}',
'src/stores/*.{js,jsx}'
],
collectCoverage: true
};
module.exports = conf;
y
- mock测试数据,单独定义在mock.js文件中
- 设置全局变量jest.setup.js(在测试文件执行之前,执行该文件)
- 测试引入enzyme包
- 测试所需要依赖的npm包,挂载到global上
- 测试所需要的所有mock数据,挂载到global上
- 定义测试所需的所有异步 api mock数据
import Enzyme, { shallow, render, mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-15'; // React 15 需要修改对应版本
import React, { PropTypes } from 'react';
import _ from 'underscore';
import config, {
order,
website,
errWebsite,
order2,
order3,
describeICPMainResponse,
GetAuthCodeListResponse
} from './mock';//测试数据
import 'babel-polyfill'//使用es6+的语法糖
//初始化enzyme
Enzyme.configure({
adapter: new Adapter()
});
//依赖的模块挂载到全局变量
global.shallow = shallow;
global.render = render;
global.mount = mount;
global.React = React;
global.PropTypes = PropTypes;
global._ = _;
//测试数据挂载到全局变量
global.config = config;
global.order = order
global.website = website
global.errWebsite = errWebsite
global.order2 = order2
global.order3 = order3
global.describeICPMainResponse = describeICPMainResponse
global.GetAuthCodeListResponse = GetAuthCodeListResponse
//模拟api request方法的封装
jest.mock('uc_common_components', () => ({
getNgService: () => {},
queryService: {
query: () => (Promise.resolve())
}
}), {
virtual: true
})
//模拟api接口返回值
jest.mock('models/apis', () => ({
asyncCheckIp: (value) => true,
describeICPConfig: () => Promise.resolve({
...global.config
}),
createICPPicture: () => {},
createICPOrder: () => {},
describeICPOrder: () => Promise.resolve({
RetCode: 0,
ICP: []
}),
modifyICPOrder: () => Promise.resolve({
RetCode: 0
}),
describeICPMain: () => Promise.resolve(global.describeICPMainResponse),
deleteICPOrder: () => {},
describeICPPicture: () => {},
applyICPCurtain: () => {},
DescribeWeb: () => {},
asyncCheckOrganizerName: () => {},
checkIsDisabledEdit: () => {},
ExtractICPType: () => {},
CheckAuthNameAndPicName: () => {},
GetAuthCodeList: () => Promise.resolve(global.GetAuthCodeListResponse),
GetAuthIpList: () => {},
CreateAuthCode: () => {},
CheckAuthCode: () => {},
CheckIsAuth: () => {},
}))
- 根据测试报告覆盖情况,完善测试用例
目前写的测试用例:
如下图:分为几个目录,公共函数部分、stores数据部分、components基础组件部分
工具类函数,单元测试例子:
//举个例子,写测试校验身份证号码的测试用例
const { IdentityCodeValid } = require('utils/validateRule.js');
test('valid identityCode to equal {pass : true}', () => {
expect(IdentityCodeValid('411325199510037826')).toEqual({"pass": true, "tip": ""});
});
test('valid identityCode to equal {pass : false, tip:"身份证号格式错误"}', () => {
expect(IdentityCodeValid('4132519951003782X')).toEqual({"pass": false, "tip": "身份证号格式错误"});
});
test('valid identityCode to equal {pass : false, tip: "地址编码错误"}', () => {
expect(IdentityCodeValid('47132519951003782X')).toEqual({"pass": false, "tip": "地址编码错误"});
});
test('valid identityCode to equal {pass : false, tip: "证件号录入错误"}', () => {
expect(IdentityCodeValid('41132519951003782X')).toEqual({"pass": false, "tip": "证件号录入错误"});
});
表单校验,单元测试用例
//表单中,该字段可能会出现的所有情况,都列举出来。
test('valid websiteForm Domain is true', async () => {
try {
let result = await websiteCheckMap['Domain'](website.Domain, website, order)
expect(result).toBe(true)
} catch (e) {
expect(e).toBe('error')
}
})
test('valid websiteForm Domain [aaa.com] is 域名格式错误', async () => {
try {
let result = await websiteCheckMap['Domain'](['aaa.com'], website, order)
expect(result).toBe('域名格式错误')
} catch (e) {
expect(e).toBe('error')
}
})
test('valid websiteForm Domain is 网站域名证书不能为空!', async () => {
try {
let result = await websiteCheckMap['Domain']([{
"Domain": "sss22.com",
"IsMain": 1,
"key": "main_domain_30"
}], website, order)
expect(result).toBe('网站域名证书不能为空!')
} catch (e) {
expect(e).toBe('error')
}
})
test('valid websiteForm Domain is 请输入正确的域名', async () => {
try {
let result = await websiteCheckMap['Domain']([{
"CerificationPicture": ["6e9a7b8d2bc4ae59a588b24de4bf243389323920.jpg"],
"Domain": "sss22.websites",
"IsMain": 1,
"key": "main_domain_30"
}], website, order)
expect(result).toBe('请输入正确的域名')
} catch (e) {
expect(e).toBe('error')
}
})
test('valid websiteForm Domain [] is 域名列表不能为空!', async () => {
try {
let result = await websiteCheckMap['Domain']([], website, order)
expect(result).toBe('域名列表不能为空!')
} catch (e) {
expect(e).toBe('error')
}
})
test('valid websiteForm Domain null is 域名列表不能为空!', async () => {
try {
let result = await websiteCheckMap['Domain'](null, website, order)
expect(result).toBe('域名列表不能为空!')
} catch (e) {
expect(e).toBe('error')
}
})
stores数据管理部分
import AuthCodeStore from 'stores/authCode'
//主要是测试,store调用文件中的刷新数据函数时,数据是否是自己预设的模拟数据
test('valid AuthCodeStore refresh return CodeList', async () => {
try {
await AuthCodeStore.refresh()
const authCodeList = AuthCodeStore.get()
expect(authCodeList).toEqual(
//判断返回的数组中,是否包含预设的数据
expect.arrayContaining(
[{
"Code": "7b7a445a-6649-419c-8d5f-8d79812bd2c8",
"TargetEmail": "wei.yu@ucloud.cn",
"Status": "未使用"
}]
)
)
//清空数据
AuthCodeStore.dispatch([])
//调用获取数据方法
const authCodeList2 = AuthCodeStore.get()
//判断数据此时是否为空数组
expect(authCodeList2).toEqual([])
} catch (e) {
expect(e).toBe('error')
}
})
组件的校验
import LanguageSelect from 'components/language-select/LanguageSelect'
import renderer from 'react-test-renderer'
import 'jest-styled-components'
describe('test suite: LanguageSelect component', () => {
//生成组件快照,如果组件被修改,再次执行测试,会报错提醒。
//1、需要重新生成快照,2、下面的测试用例需要同步更新
it('case: expect LanguageSelect create a Snapshot', () => {
const wrapper = renderer.create(
<LanguageSelect
config={global.config}
expandedNum={2}
values = {[]}
collection='SortedLanguage'
/>
).toJSON();
expect(wrapper).toMatchSnapshot();
});
//判断主要渲染的元素,是否缺失
it('LanguageSelect has children 17', () => {
let values = []
const onChange = jest.fn(v=>{
values = v
})
const wrapper = mount(
<LanguageSelect
config={global.config}
expandedNum={2}
values = {values}
onChange={onChange}
collection='SortedLanguage'
/>); //完全渲染
expect(wrapper.find('span.uc-fe-checkbox').length).toEqual(2)
wrapper.find('a').simulate('click')
expect(wrapper.find('span.uc-fe-checkbox').length).toEqual(global.config['SortedLanguage'].length)
});
//模拟onChange事件
it('LanguageSelect trigger onChange update values', () => {
let values = []
const onChange = jest.fn(v=>{
values = v
})
const wrapper = mount(
<LanguageSelect
config={global.config}
expandedNum={2}
values = {values}
onChange={onChange}
collection='SortedLanguage'
/>); //完全渲染
wrapper.props().onChange([1,2,3])
wrapper.update();
expect(values).toEqual([1,2,3])
});
});
过程中遇到的问题,如何解决?
1、备案系统引用的公共模块(比如组件库,样式库)是通过webpack定义externals,将挂载到window全局变量中的变量,赋值给对应的模块名。jest中如何识别,测试用例代码中怎么引用这些公共模块?
原因:jest是node环境运行,全局变量是global,获取不到浏览器中的全局变量window
我的方法:
- npm下载这些模块的包
- 配置在jest的config中的moduleNameMapper
2、定义别名,webpack中是通过alias来定义目录的别名,jest如何与目前文件引用名保持一致定义?
3、api异步调用接口如何模拟?
原因: api返回的结构是promise
我的方法: 加载babel-polyfill包,使用es6中的promise,让api返回promise
4、react基础组件如何测试?
- 基础组件主要包括:表单类基础组件、静态样式类组件、路由分配组件等
- 表单类基础组件:模拟onChange方法,验证onChange触发后 values的值是否符合预期。(疑问🤔️:如果模拟页面点击来触发onChange/onSelect等函数?)
- 静态样式类组件:生成组件快照,同时验证dom元素中必要的内容是否符合预期
- 路由分配组件:暂时还没有思路,待研究。
接下来的完善计划有哪些?
1、完善测试用例 提高覆盖率
2、将测试用例提交到git上,发布编译时,过滤掉测试用例
3、学习git CI/CD 将单元测试与此结合使用。