Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
51 changed files
with
7,576 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
# 前端单元测试经验分享 | ||
|
||
## 单元测试概念讲解 | ||
|
||
![](./docs/boss.jpg) | ||
|
||
以一张图开篇。请想象,马上要攻打一个飞行boss,但是突然发现自己的装备和技能点都分给了近身攻击,结果一定是掀桌子不玩了 (/‵Д′)/~ ╧╧ | ||
|
||
### 误区(以下内容全是误解) | ||
|
||
* 测试是测试人员才需要写的。 | ||
|
||
🐾 测试人员是黑盒测试,不管程序内部用的何种语言,如何实现,测试人员只测试表现出来的功能。只有详细了解代码每个内部功能模块的人才能编写对应的单元测试,即开发者自己编写。 | ||
|
||
* 测试可以保证无bug。 | ||
|
||
🐾 测试可以减少bug,所有测试到的地方可以减少bug,但总会有编码估计不到的地方,这是就需要同时修正功能与添加测试用例。 | ||
|
||
* 只要我每次页面上都点过了,测试也没啥用。 | ||
|
||
🐾 第一次我认真点点,第二次我认真点点,...,第N次之后,我写的代码没问题,发布吧 []~( ̄▽ ̄)~* | ||
|
||
* 我的代码里功能太多了,根本没法测试。 | ||
|
||
🐾 八成是代码拆分没设计好,测试不光是功能的辅助,也是功能的镜子。单元测试是重构的好基友。 | ||
|
||
### 概念解释 | ||
|
||
#### runner | ||
|
||
运行环境,负责把测试跑起来,并在整个测试环境中添加一些全局方法(例如: `describe`、`test`)。 | ||
|
||
* mocha: 前后端环境通用,只负责把测试跑起来,可以很方便的搭配其他各种断言库运行,配置方便,年头多,配套成熟。 | ||
|
||
* karma: 运行实际浏览器进行测试的runner,需要和其他断言库配合,只运行浏览器环境。配置较难,debug也不太友好。 | ||
|
||
#### assertion | ||
|
||
断言库,在每个测试用例中用来判断结果是否正确的工具。 | ||
|
||
* [expect.js](https://github.com/mjackson/expect) | ||
|
||
```javascript | ||
expect(isTrue()).toBe(true) | ||
``` | ||
|
||
* [should.js](http://shouldjs.github.io/) 向所有对象的顶级Object的prototype中添加了should方法,变更了所有对象的should方法,个人感觉不太好。 | ||
|
||
```javascript | ||
(isTrue()).should.be.true(); | ||
``` | ||
|
||
严格来说,断言库只是语法糖,可以用自己的代码替代。以一个判断函数运行结果为true的测试的为例。 | ||
|
||
在nodejs环境中,[assert](https://nodejs.org/api/assert.html)是内置库,无需安装第三方工具。 | ||
|
||
```javascript | ||
// 用断言写 | ||
const assert = require('assert'); | ||
assert.equal(isTrue(), true) | ||
|
||
// 自己写 | ||
if (!isTrue()) { | ||
throw new Error(`测试失败: isTrue 运行结果是 ${isTrue()}`) | ||
} | ||
``` | ||
|
||
但各种断言库,可以提供人性化的编程体验,和友好的错误提示与debug环境,除非只有几个测试时使用内置assert断言,否则还是推荐使用一些较成熟的断言库。 | ||
|
||
#### coverage | ||
|
||
测试覆盖率。自己的代码被测试了多少,有哪些地方没覆盖到呢? | ||
|
||
* 著名的[istanbul](https://istanbul.js.org/),需要与测试runner配合使用。 | ||
|
||
#### all in one | ||
|
||
* [jasmine](https://github.com/jasmine/jasmine-npm) 自带runner与断言,但需要自己搭配istanbul统计测试覆盖率。 | ||
|
||
* [jest](https://jestjs.io/) 自带runner、断言、测试覆盖率(内置instanbul)统计。缺点,只能在nodejs中运行,需要引入jsdom模拟浏览器环境。 | ||
|
||
#### 总结 | ||
|
||
* 轻量级的小库,推荐mocha + expect | ||
|
||
* 其他情况推荐jest。 | ||
|
||
### 我们为什么需要单元测试 | ||
|
||
* 代码互相关联,改1个bug引起3个bug,按下葫芦冒起来瓢。 | ||
|
||
* 因为复用代码需要兼容各种环境,在测试中可以自动模拟大部门环境因素。 | ||
|
||
* 让我们的库看着并且真的很健壮。想象一下当我发现了一个开源工具或类库想应用时,发现这玩意连个单元测试都没有 →_→ | ||
|
||
* 节省每次测试的人力成本,死道友不死老衲,让机器跑去吧。 | ||
|
||
* 单元测试不仅仅是程序的附属,是提升程序员代码分层与抽象能力不可或却的一环。 | ||
|
||
* 当我们有自己的包可以放到npm上显摆一下的时候,没有配套的测试用例,想想还是算了 \`(+﹏+)′ | ||
|
||
* self promotion,有配套测试功能很酷,代码实现的功能点可以用测试用例为依据证明实现的功能性与健壮性。 | ||
|
||
* 当项目庞大之后,没有测试配套的基础组件是不敢动的,只能copy实现一个类似的功能,导致项目永远在膨胀。有测试用例的代码,使人可以更有信心的去扩展功能。 | ||
|
||
## jest,以下以jest为例讲解 | ||
|
||
* 建立新项目,添加各种依赖,`src`与测试目录`__tests__` | ||
|
||
[STEP1](./docs/init.md) | ||
|
||
* 以纯函数为例,同时编写功能与测试 | ||
|
||
* 单独测试某个文件,添加`--watch 参数` | ||
|
||
[toFixed src](./example/src/toFixed.ts) | ||
[toFixed test](./example/__tests__/toFixed.test.ts) | ||
|
||
* 编写异步测试 | ||
|
||
[fetchUser src](./example/src/fetchUser.ts) | ||
[fetchUser test](./example/__tests__/fetchUser.test.ts) | ||
|
||
* 添加react,添加jsdom环境,配置jest,配置babel | ||
|
||
* react本身也有测试配套工具: `react-test-util`、`react-test-renderer`、`react-dom/test-utils`、`react-test-renderer/shallow`。 | ||
|
||
* 引入[enzyme](https://airbnb.io/enzyme/),就是语法糖,加速人工写react测试的速度。 | ||
|
||
[jest.config.js](./example/jest.config.js) | ||
[jest/setup.js](./example/jest/setup.js) | ||
|
||
* 编写一个简单react组件与测试用例,使用beforeEach、afterEach精简测试用例 | ||
|
||
[Search src](./example/src/component/Search.ts) | ||
[Search test](./example/__tests__/component/Search.test.ts) | ||
|
||
* 重点测试各种可能导致内存泄漏的情况 | ||
|
||
* 使用`shallow`代替`mount`,在不需要测试子组件时可加速测试运行速度。 | ||
|
||
[SearchWithEvent src](./example/src/component/SearchWithEvent.ts) | ||
[SearchWithEvent test](./example/__tests__/component/SearchWithEvent.test.ts) | ||
|
||
* mock外部环境 | ||
|
||
[logout src](./example/src/logout.ts) | ||
[logout test](./example/__tests__/logout.test.ts) | ||
|
||
🐞 测试用例默认是串行运行的,上一个测试用例修改了外部环境之后的测试用例用的都会是dirty的全局环境,出现各种莫名其妙的错误。很多时候,单独运行一个测试可以通过,但是一整体运行就怎么也不能跑通而且很难找到错误原因。 | ||
|
||
* 生成测试覆盖率统计。 | ||
|
||
🐞 问题:测试覆盖率达到100%就说明这个小功能已经测到头了吗?以email校验为例说明覆盖率百分之百仍然没有覆盖完整,需要在人工测到bug之后,不断晚上功能与测试用例。 | ||
|
||
[isEmail src](./example/src/isEmail.ts) | ||
[isEmail test](./example/__tests__/isEmail.test.ts) | ||
|
||
* 配合typescript,详见`jest.config.js的transform` | ||
|
||
在js时代,很多时候写一个有参数函数的单元测试,我都觉得应该写一下参数类型错误的判断,在运行时先判断类型是否正确,错误则抛出错误。在有了ts之后,这种校验交给ts即可。只有在接收外部数据作为参数的情况下才需要运行时校验类型。 | ||
|
||
* 引入mobx环境,模拟需要的store。 | ||
|
||
[mobx component src](./example/src/component/List.tsx) | ||
[mobx component test](./example/__tests__/component/List.test.tsx) | ||
|
||
* 将逻辑移出组件 | ||
|
||
* 将战火燃于国门之外---将所有可外置的逻辑都转移到组件(web组件,包含react、vue等前端组件)外部。 | ||
|
||
[complex search src](./example/src/component/ComplexSearch) | ||
[complex search test](./example/__tests__/component/ComplexSearch) | ||
|
||
## 最佳实践 | ||
|
||
I.纯函数,测试和代码同步出 | ||
|
||
II.其他各种公共代码,在功能与代码拆分稳定后补全测试 | ||
|
||
III.业务代码变化快,在编码之初先别着急测试,因为随着页面的搭建结构可能会全部重构。在稳定后,可针对关键业务添加一些测试,或在出现bug后针对bug添加测试。 | ||
|
||
IV.将逻辑尽可能移出组件,组件中调用功能。组件的归组件,功能的归功能。 | ||
|
||
V.git工作流搭配(配置过程内容有些多,需要另开一个专题)。 | ||
|
||
* 通过`husky`或`yorkie`添加本地git push钩子,推送时自动运行测试 | ||
|
||
* 通过与gitlab pipeline结合,配置`gitlab-ci.yml`,并配置gitlab的runner。 | ||
|
||
## 如何提升 | ||
|
||
找几个自己常用的开源库,clone代码之后看看他们的测试用例,并运行体验结果。 | ||
|
||
例如:react、vue、lodash | ||
|
||
回想第一张开篇图。测试的技能点需要重新积累经验值分配。 | ||
|
||
![skill](./docs/skill.png) | ||
|
||
配合这张不是很准确但大体能反映关系的技能树图,中间最下面的是编程基础技能,左侧的分支可以比做测试技能,在升到高层时,这两项技能会互相影响,如果融合后可衍生高级技能---抽象、分层、重构、架构、工程等。 | ||
|
||
## 推荐阅读 | ||
|
||
* 深入理解[jest](https://jestjs.io/docs/zh-Hans/getting-started),有官方中文文档 []~( ̄▽ ̄)~* | ||
|
||
* 如果使用enzyme配合测试react,需要通读[enzyme官方文档](https://airbnb.io/enzyme/)。 | ||
|
||
* [编写可测试的JavaScript代码](https://item.jd.com/10357107991.html) |
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
``` | ||
mkdir example | ||
cd example | ||
yarn init -y | ||
// 后来依赖越来越多,还是以example文件夹中的package.json中的为准 | ||
yarn add jest @types/jest typescript ts-jest react antd enzyme @types/enzyme jsdom | ||
mkdir src | ||
mkdir __tests__ | ||
``` |
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
module.exports = { | ||
root: true, | ||
parser: '@typescript-eslint/parser', | ||
plugins: ['prettier'], | ||
parserOptions: { | ||
ecmaVersion: 2018, | ||
sourceType: 'module', | ||
ecmaFeatures: { | ||
jsx: true, | ||
}, | ||
}, | ||
extends: [ | ||
'prettier', | ||
'prettier/react', | ||
], | ||
env: { | ||
browser: true, | ||
node: true, | ||
jest: true, | ||
es6: true, | ||
}, | ||
rules: { | ||
'prettier/prettier': [ | ||
'error', | ||
{ | ||
eslintIntegration: true, | ||
stylelintIntegration: true, | ||
printWidth: 120, | ||
useTabs: false, | ||
tabWidth: 2, | ||
singleQuote: true, | ||
semi: false, | ||
trailingComma: 'all', | ||
jsxBracketSameLine: false, | ||
endOfLine: 'auto', | ||
}, | ||
], | ||
}, | ||
} |
16 changes: 16 additions & 0 deletions
16
jest/example/__tests__/component/ComplexSearch/index.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import * as React from 'react' | ||
import { mount } from 'enzyme' | ||
import { Form, DatePicker } from 'antd' | ||
|
||
import { ComplexSearch, IProps } from '../../../src/component/ComplexSearch' | ||
import { disabledDateFrom, disabledDateTo } from '../../../src/component/ComplexSearch/util' | ||
|
||
test('测试日期校验逻辑正确传入', () => { | ||
const FormWrapper = Form.create<IProps>()(({ form }) => <ComplexSearch form={form} />) | ||
const app = mount(<FormWrapper />) | ||
const datePickers = app.find(DatePicker) | ||
expect(datePickers).toHaveLength(2) | ||
const instance = app.find(ComplexSearch).instance() as ComplexSearch | ||
expect(datePickers.at(0).prop('disabledDate')).toBe(instance.disabledDateFrom) | ||
expect(datePickers.at(1).prop('disabledDate')).toBe(instance.disabledDateTo) | ||
}) |
71 changes: 71 additions & 0 deletions
71
jest/example/__tests__/component/ComplexSearch/util.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { WrappedFormUtils } from 'antd/es/form/Form' | ||
import moment from 'moment' | ||
|
||
import { disabledDateFrom, disabledDateTo } from '../../../src/component/ComplexSearch/util' | ||
|
||
describe('page/DepartmentTree/util', () => { | ||
it(`disabledDateFrom,业务逻辑, | ||
最早2018-06-01, | ||
时间间隔最多40天, | ||
from可以等于to, | ||
不能晚于to, | ||
最晚为今日 | ||
`, () => { | ||
let to: moment.Moment | undefined | ||
/** mock一个antd的form对象 */ | ||
const form = ({ | ||
getFieldValue() { | ||
return to | ||
}, | ||
} as unknown) as WrappedFormUtils | ||
|
||
expect(disabledDateFrom(form)(null)).toBe(false) | ||
// 起始日期 | ||
expect(disabledDateFrom(form)(moment('2018-06-01'))).toBe(false) | ||
expect(disabledDateFrom(form)(moment('2018-05-31'))).toBe(true) | ||
// 今天 | ||
expect(disabledDateFrom(form)(moment())).toBe(false) | ||
expect(disabledDateFrom(form)(moment().add(1, 'day'))).toBe(true) | ||
|
||
to = moment('2019-03-01') | ||
// 可以等于to | ||
expect(disabledDateFrom(form)(to)).toBe(false) | ||
// 不能晚于to | ||
expect(disabledDateFrom(form)(to.clone().add(1, 'day'))).toBe(true) | ||
// 间隔不能大于40天 | ||
expect(disabledDateFrom(form)(to.clone().subtract(40, 'day'))).toBe(false) | ||
expect(disabledDateFrom(form)(to.clone().subtract(41, 'day'))).toBe(true) | ||
}) | ||
|
||
it(`disabledDateTo,业务逻辑, | ||
最早2018-06-01之后的40天, | ||
时间间隔最多40天, | ||
from可以等于to, | ||
不能早于to, | ||
最晚为今日 | ||
`, () => { | ||
let from: moment.Moment | undefined | ||
/** mock一个antd的form对象 */ | ||
const form = ({ | ||
getFieldValue() { | ||
return from | ||
}, | ||
} as unknown) as WrappedFormUtils | ||
expect(disabledDateTo(form)(null)).toBe(false) | ||
// 起始日期 | ||
expect(disabledDateTo(form)(moment('2018-06-01').add(40, 'day'))).toBe(false) | ||
expect(disabledDateTo(form)(moment('2018-06-01').add(39, 'day'))).toBe(true) | ||
// 今天 | ||
expect(disabledDateTo(form)(moment())).toBe(false) | ||
expect(disabledDateTo(form)(moment().add(1, 'day'))).toBe(true) | ||
|
||
from = moment('2019-03-01') | ||
// 可以等于to | ||
expect(disabledDateTo(form)(from)).toBe(false) | ||
// 不能早于to | ||
expect(disabledDateTo(form)(from.clone().subtract(1, 'day'))).toBe(true) | ||
// 间隔不能大于40天To | ||
expect(disabledDateTo(form)(from.clone().add(40, 'day'))).toBe(false) | ||
expect(disabledDateTo(form)(from.clone().add(41, 'day'))).toBe(true) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import * as React from 'react' | ||
import { Table } from 'antd' | ||
import { api, List } from '../../src/component/List' | ||
import { mount } from 'enzyme' | ||
|
||
const wrap = () => mount(<List />) | ||
|
||
describe('component/List', () => { | ||
test('组件加载时发起请求', () => { | ||
const spy = jest.spyOn(api, 'fetchUserList') | ||
expect(spy).not.toHaveBeenCalled() | ||
const wrapper = wrap() | ||
expect(spy).toHaveBeenCalled() | ||
spy.mockRestore() | ||
}) | ||
|
||
test('store的list放到Table里', async () => { | ||
const wrapper = wrap() | ||
await api.fetchUserList() | ||
wrapper.update() | ||
const table = wrapper.find(Table).at(0) | ||
expect(table.prop('dataSource')).toHaveLength(2) | ||
}) | ||
}) |
Oops, something went wrong.