AntdPro项目前端测试的探索

测试规范

测试工具的选择

在本项目中以 ant design pro 项目为测试项目,测试工具最终选型为

上述工具除react-test-render外,在ant design pro项目中都已内置

jest的初体验

安装jest

npm i jestyarn add jest

jest 官方推荐使用 yarn 工具

创建sum.js样本文件

1
2
3
4
function sum(a, b) {
return a + b;
}
module.exports = sum;

创建测试文件sum.test.js

1
2
3
4
5
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});

在package.json中添加要执行的命令

1
2
3
4
5
{
"scripts": {
"test": "jest"
}
}

执行jest

npm run testyarn test

  1. jest 默认遵循 AMD 规范,import 等语法会导致测试不通过。
  2. 为满足测试的需求,ES6、JSX等语法支持可通过手动配置babel支持。

匹配器

在上述代码中 .toBe 这个精准匹配测试结果的语法叫做匹配器。匹配器主要有以下几种:

普通匹配器

Truthiness*(译:真实性。即为特殊类型匹配器)*

数字匹配器

字符串(可使用正则表达式)

Arrays and iterables(数组和可迭代对象)

其他

执行异步代码

回调函数

1
2
3
4
5
6
7
8
9
10
11
12
test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

Jest会等done回调函数执行结束后,结束测试。若 done() 函数从未被调用,测试用例会=执行失败(显示超时错误)。若 expect 执行失败,它会抛出一个错误,后面的 done() 不再执行。

Promises

1
2
3
4
5
test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});

Async/Await

1
2
3
4
5
6
7
8
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (e) {
expect(e).toMatch('error');
}
});

Jest的代码执行顺序

beforeEach、afterEach

在每一次测试的前后执行的方法

beforeAll、afterAll

在文件执行的前后执行的方法(只执行一次)

describe

describe会将测试文件分组,先执行每一个describe,再去执行内部的test

**如果测试失败,第一件要检查的事就是,当仅运行这条测试时,它是否仍然失败。

其他食用方法可查看jest官方文档

AntdPro中的单元测试

AntdPro项目测试介绍

这是一个标准AntdPro项目的文件结构,其中e2e tests目录即为测试文件,tests内包含一些测试前的基本配置和环境检测,例如浏览器检测、puppeteer插件检测、测试语法环境检测和项目测试入口文件。

e2e目录结构如下,其中包含了一个E2E的测试用例,以及一个空less文件的mock。

E2E测试:即端到端测试(End To End),也叫冒烟测试,用于测试真实浏览器环境下前端应用的流程和表现,相当于代替人工去操作应用。

自定义jest单元测试

由于现有的项目文件夹下,单元测试文件是直接放在同级的组件下的,为了能形成统一,我们遵循jest规范在src目录下新建一个__test__目录用于单元测试。因为单元测试包括项目中的公共组件、页面组件以及公用第三方函数,所以我们在__test__下继续新建目录,现在的目录结构如下:

分别对应各自的测试功能。

为了测试的简洁,在单元测试中我们引入enzyme测试库,并添加React16的语法支持。

1
2
3
4
// enzyme的React16语法支持
const Enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');
Enzyme.configure({ adapter: new Adapter() });

因为此段配置需要在测试之前完成,我们可以在__test__下新建setup.js文件,用来做一些测试的初始化配置。另大家也可以在tests/beforeTest.js或其他测试之前的文件下添加上述配置。

并且在项目根目录jest.config.js下添加启动文件,很多的单元测试配置都会在这个文件下完成。

1
2
3
4
5
6
7
8
9
10
11
12
// jest.config.js
module.exports = {
testURL: 'http://localhost:8000',
setupFilesAfterEnv: ["./src/__test__/setup.js"], // 添加启动文件
testEnvironment: './tests/PuppeteerEnvironment',
verbose: false,
globals: {
ANT_DESIGN_PRO_ONLY_DO_NOT_USE_IN_YOUR_PRODUCTION: false,
localStorage: null,
},
};

最后,也是最关键的一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// eslint-disable-next-line
// const NodeEnvironment = require('jest-environment-node'); // 注销
const NodeEnvironment = require('jest-environment-jsdom'); // 添加
const getBrowser = require('./getBrowser');
class PuppeteerEnvironment extends NodeEnvironment {
// Jest is not available here, so we have to reverse engineer
// the setTimeout function, see https://github.com/facebook/jest/blob/v23.1.0/packages/jest-runtime/src/index.js#L823
setTimeout(timeout) {
if (this.global.jasmine) {
// eslint-disable-next-line no-underscore-dangle
this.global.jasmine.DEFAULT_TIMEOUT_INTERVAL = timeout;
} else {
this.global[Symbol.for('TEST_TIMEOUT_SYMBOL')] = timeout;
}
}
async setup() {
const browser = await getBrowser();
const page = await browser.newPage();
this.global.browser = browser;
this.global.page = page;
}
async teardown() {
const { page, browser } = this.global;
if (page) {
await page.close();
}
if (browser) {
await browser.disconnect();
}
if (browser) {
await browser.close();
}
}
}
module.exports = PuppeteerEnvironment;

这一步的目的是更换当前的测试环境,因为在jest.config.js配置中指定了这个文件为测试环境的执行文件,但它默认是为node环境,使用它进行测试会出现一些意料之外的错误,具体原因我还未得知,如有知道的小伙伴可以在本帖下回复,一同讨论。

公用组件测试

由于公用组件高度封装的特性,很少会涉及页面逻辑的部分,比较适合我们的第一个单元测试。我的公用测试组件为MyTableWithSear,所以在compontents新建一个名为compontents.test.jsx的测试文件。

测试文件的名称可跟随自己喜好,一般的测试文件都会以.test.js(x) .test.ts(x) .spec.js为后缀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// MyTableWithSear 组件
/***
* @param {children },
* @param {mySear} 表格搜索栏,
* @param {expandtop} 表格扩展栏,
* @param {breadroutes} 头部面包屑
* @return {Element} 返回内容
* <PageHeaderWrapper breadcrumb={breadcfg}>
* <div className={`${styles.board} test-mytablewithsear-board`} style={ { boxSizing: 'border-box', padding: '20px 10px' } }>
* {expandtop}
* {mySear ? <div className={styles.searchBox}>
* {mySear}
* </div> : null}
* {children}
* </div>
* </PageHeaderWrapper>
*/

接下来我们就可以根据上面的组件详情来书写相应的组件测试用例了。首先检测一下 children参数情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from 'react'
// 快照测试第三方包
import renderer from 'react-test-renderer';
// 待测试组件
import MyTableWithSear from '../../components/MyTableWithSear/index'
// shallow 浅渲染 mount 深层渲染
import { mount } from 'enzyme';

describe('测试组件:MyTableWithSear', () => {
it('children参数预期检测', () => {
const children = <div className="children">子元素</div>
const wrapper = mount(<MyTableWithSear>{children}</MyTableWithSear>);
expect(wrapper.find('.children').length).toBe(1);
expect(wrapper.find('.children').text()).toEqual("子元素");

const children1 = "子元素二"
const wrapper1 = mount(<MyTableWithSear>{children1}</MyTableWithSear>);
expect(wrapper1.find('MyTableWithSear').text()).toEqual("子元素二");

const children2 = 3
const wrapper2 = mount(<MyTableWithSear>{children2}</MyTableWithSear>);
expect(wrapper2.find('MyTableWithSear').text()).toBe("3");

const children3 = null
const wrapper3 = mount(<MyTableWithSear>{children3}</MyTableWithSear>);
expect(wrapper3.find('MyTableWithSear')).toEqual({});
})
}

以上即为children的参数检测用例,组件的其他参数测试可依此进行。

最后运行一下这个测试文件,npm test(命令在antdpro项目中已内置,无需手动添加)

一些涉及到标签的测试用例可能会受到外层标签的影响,这时我们通常可以通过

  • 手动构建一个外层环境
  • 更换测试用例的标签

两种方法解决。在MyTableWithSear组件breadroutes参数就会存在此问题,因外层没有Route包裹Link导致出错,可以通过将Link换成普通a标签解决此问题。

快照测试

每当你想要确保你的UI不会有意外的改变,快照测试是非常有用的工具。

目前提供快照功能的测试库有很多,经过一些比较和项目的需要,最终我选择了react-test-renderer作为快照测试库,各位也可根据需要自行选择。同样我们还是用MyTableWithSear组件作为快照测试的例子。

1
2
3
4
5
6
7
// 快照测试 -- 首次运行,会在 ./snapshot 下生成快照静态文件,每次运行会对比结果是否与上次一致
describe('测试组件:MyTableWithSear', () => {
it('快照测试', () => {
const wrapper = renderer.create(<MyTableWithSear />).toJSON()
expect(wrapper).toMatchSnapshot()
})
})

快照测试的语法相对简单,但作用不容小觑,关于快照测试的其他用法大家也可以自行了解,这里不做详细描述。

页面组件测试

页面组件的单元测试可能与公用组件的测试方法稍有不同,因为页面组件可能会涉及到业务逻辑与redux连接,特别是在AntdPro项目中,umi+dva的模式已经将许多工具都进行了再封装,使得测试难度变得很大。

首先介绍一下用来作为例子的页面组件UserManage.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// UserManage.jsx
import React from 'react'
import { Form, Input, message, Space, Popconfirm, Select, Row, Col, Table, Button, Radio, DatePicker, Switch, Card, Modal } from 'antd'
import MyTableWithSear from '../../components/MyTableWithSear'
import { EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import moment from 'moment'
import { connect } from 'umi'
import styles from './index.less'

const { RangePicker } = DatePicker;
const { Option } = Select;

@connect(({ usermanage, loading }) => ({
listData: usermanage,
loadingData: loading.effects['usermanage/getlist'],
}))
class UserManage extends React.Component {
editFormRef = React.createRef();
searFormRef = React.createRef();
constructor(props) {
super(props)
this.state = {
pagination: {},
data: null,
addModalShow: false,
highDangerShow: false,
editModalShow: false,
curEditObj: {},
}
}
UNSAFE_componentWillMount() {
this.getList()
}
UNSAFE_componentWillReceiveProps() {
const { listData } = this.props
const { list, pagination, } = listData
this.setState({
data: list,
pagination
})
}
onPageChange(page) {
const { dispatch } = this.props
let { pagination } = this.state
pagination.current = page.current
pagination.pageSize = page.pageSize
dispatch({
type: "usermanage/pageChange",
payload: pagination
})
}
getList() {
const { dispatch } = this.props
const { current, pageSize } = this.state.pagination
dispatch({
type: "usermanage/getlist",
payload: { pageSize: pageSize || 10, pageNum: current || 1 }
})
}
openAddModal() {
this.setState({
addModalShow: true
})
}
switchState(row, state) {
{/* TODO */}
}
renderAddModal() {
const { addModalShow, typeBox } = this.state
return (
<Modal
width="60%"
className={styles.addModal}
visible={addModalShow}
onOk={() => this.setState({ addModalShow: false })}
onCancel={() => this.setState({ addModalShow: false })}
>
{/* TODO */}
</Modal>
)
}
renderEditModal() {
const { editModalShow, typeBox } = this.state
return (
<Modal
width="60%"
className={styles.addModal}
visible={editModalShow}
onOk={() => this.setState({ editModalShow: false })}
onCancel={() => this.setState({ editModalShow: false })}
>
{/* TODO */}
</Modal>
)
}
openEditModal(row) {
if (this.editFormRef.current) {
this.editFormRef.current.setFieldsValue(row);
}
this.setState({ curEditObj: row, editModalShow: true });
}
renderHighDangerModal() {
const { highDangerShow } = this.state
const columns = [
{/* TODO */}
]
return (
<Modal
width="60%"
visible={highDangerShow}
onOk={() => this.setState({ highDangerShow: false })}
onCancel={() => this.setState({ highDangerShow: false })}
>
{/* TODO */}
</Modal>
)
}
renderSearBox() {
const { dispatch } = this.props
const { current, pageSize } = this.state.pagination
const search = () => {
dispatch({
type: "usermanage/getlist",
payload: { pageSize: pageSize || 10, pageNum: current || 1, ...this.searFormRef.current.getFieldsValue() }
})
}
return (
<Form
layout="inline"
ref={this.searFormRef}
>
{/* TODO */}
</Form>
)
}
render() {
let { loadingData } = this.props
const { pagination, data } = this.state
const columns = [
{/* TODO */}
]
return (
<MyTableWithSear mySear={this.renderSearBox()}>
<Table loading={loadingData} pagination={pagination} columns={columns} dataSource={data} scroll={ { x: 2000 } } />
{this.renderAddModal()}
{this.renderEditModal()}
{this.renderHighDangerModal()}
</MyTableWithSear>
)
}
}
export default UserManage

这个页面组件的信息是不全面的,一些项目敏感信息已删除,但是仍能看出逻辑。

在我看来,页面组件的单元测试无非三件事:

但是在这之前我们得先将测试组件构建出来,我试过用这种最常见的方法去构建

1
2
3
4
5
6
7
8
9
10
// UserManage.test.js
import React from 'react';
import { mount } from 'enzyme';
import UserManage from '../../pages/UserManage/index'
describe('测试组件:UserManage', () => {
it('基础标签检测', () => {
const wrapper = mount(< UserManage />)
// TESTS
}
}

接着在后面写了一些测试用例,然后就报错了 ……

TypeError: (0 , _umi.connect) is not a function这是错误提示,在上面已经说过umi+dva已经将redux进行了再封装(项目根目录下的.umi文件夹下可查看封装细节),所以从umi引入的connect单纯的用作单元测试上会产生umi的依赖问题(描述的不清晰,也可能是我的理解不够深刻)。在GitHub上也有一篇关于这个问题的文章,文章详情。很遗憾,里面提供的解决方案比较少,且对我的项目不起作用,各位也可以自行尝试。

然后我找到了另一种解决办法:

到此,页面组件的单元测试就基本完成了,尽管它在dva的表现上不那么完美,但好在基本的测试功能确实是能达到的。如果有更好解决方案的小伙伴也可在文章下方留言哦。

第三方函数测试

第三方函数的测试应该是比较简单的一种了,测试文件可放在./src/__test__/untils下,而且在原有的项目./src/utils目录下已经有一个名为untils.test.js的第三方函数测试文件,测试的用例流程也很全面,各位可以按照格式构建自己的测试即可。

以上就是单元测试的全部内容,单元测试在前端测试中占的比重较高,而且在AntdPro项目中的测试覆盖率也是以单元测试在代码中所涉及的行数去计算的,所以单元测试的内容在前端测试中显得尤为重要。

E2E测试

在AntdPro项目中,umi已经为我们搭建了E2E测试环境了,其默认的E2E测试工具为puppeteer,puppeteer语法与emzyme有相似之处,如果只用于写E2E测试的话,上手比较容易,关于puppeteer的语法请点击puppeteer文档

下面我会用puppeteer写一个关于网站登录的E2E测试用例,在这之前,为了更好的观察E2E测试的执行过程,我们可以修改一下./tests/getBrowser.js下的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// getBrowser.js
/* eslint-disable global-require */
/* eslint-disable import/no-extraneous-dependencies */
const findChrome = require('carlo/lib/find_chrome');

const getBrowser = async () => {
try {
// eslint-disable-next-line import/no-unresolved
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch({
headless: false, // *********在这里新增参数**********
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-zygote',
'--no-sandbox',
],
});
return browser;
} catch (error) {
// console.log(error)
}

try {
// eslint-disable-next-line import/no-unresolved
const puppeteer = require('puppeteer-core');
const findChromePath = await findChrome({});
const { executablePath } = findChromePath;
const browser = await puppeteer.launch({
executablePath,
headless: false, // *********在这里新增参数**********
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-zygote',
'--no-sandbox',
],
});
return browser;
} catch (error) {
console.log('???? no find chrome');
}
throw new Error('no find puppeteer');
};

module.exports = getBrowser;

这里的修改是为了在E2E文件执行期间我们可以观察到浏览器内部的测试细节,便于我们调整测试走向,当然在正式测试时因关闭此项配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// UserManage.e2e.js
// regeneratorRuntime 引入运行时
const regeneratorRuntime = require('regenerator-runtime/runtime.js')
const { getTestUrl } = require('../../../utils/test-config.js')
beforeEach(async() => {
await page.setViewport({ width: document.body.clientWidth, height: document.body.clientHeight });
await page.goto(getTestUrl());
await page.evaluate(() => {
// 设置localStroge,绕过登录检测
// localStorage.setItem('antd-pro-authority', '["admin"]');
// localStorage.setItem('userInfo', JSON.stringify({ userid: "00000001" }));
});
});
describe("用户管理页面e2e测试", () => {
it('e2e登录', async() => {
await page.goto(getTestUrl("/login"))
await page.waitFor("form.ant-form")
// await page.waitFor(3000)
await page.type('#userName', 'admin')
await page.type('#password', '123456')
await page.click('button[type=submit]')
let loginRes
await page.on('requestfinished', async request => {
if (request.url().endsWith('/login/account')) {
let res = await request.response().json()
loginRes = res
}
}).waitFor(2000);
// 添加延时以便我们能看清操作
await page.waitFor(3000)
expect(loginRes.status).toEqual("ok")
}, 15000)
}

这一段就是测试用户登录流程的E2E代码了,getTestUrl是我自封装的函数,它会根据传入参数返回测试地址。

在这个测试中,首先进入登陆页面,然后陆续填充用户名密码,点击提交按钮,随后监听请求的返回结果,核验返回的内容即完成本个测试。

一些注意事项:

  • 当我们在本地运行npm test时,应确保测试地址是可用的,否则E2E会直接报错。你也可以运行npm test:all命令,它会自动帮你启动项目,并完成所有的测试工作。但是采用这种方式要记得在测试完毕后关闭命令行窗口,这样程序才能回收测试残留的子进程,防止资源占用。
  • E2E测试还有一个前提条件是本地环境必须安装有chrome内核,因为puppeteer是基于chrome浏览器运行的。
  • 在打开页面之前,如果想向浏览器LocalStroge写入一些内容时,应事先允许浏览器使用第三方cookie。

总结

以上就是近几天我研究AntdPro项目前端测试的主要内容,其中可能有许多测试场景和意外在本文中都没有涉及到,有一些测试完成程度也不尽如人意,就像页面组件的单元测试一样,但好在,经过一番折腾最终勉强达到了测试效果。随后在真正普及项目测试时,肯定还会遇到许多问题,我也会持续关注,对上述测试流程进行优化以达到更好的效果。

附:

一些建议:

  1. 一个大的测试组件内部标签众多,可以适量的加一些id``class加以甄别。

  2. 公用组件的稳定性是比较重要的,为了测试组件的健壮性,通常可以从参数类型入手测试用例。

  3. 涉及到类似Link的测试,需要外部有标签包裹(Route)时,我们也可以尝试替换内部标签(将Link替换为a),这样我们也免于构建外部环境。

  4. 本文中提到的测试目录结构是我个人构建的,各位也可以根据自己的喜好构建适合自己的测试目录。

作者释: 由于本人水平一般,如果在文中有误导之处,还请各位能及时指正。

上次更新 2021-01-28