前端自动化测试 Vue & React

https://jestjs.io/docs/en/getting-started

记录一些文档没写全或者扩展性的写法

基本配置

Jet watch

监听测试文件

1
2
3
4
5
6
{
"//":"监听所有文件",
"test": "jest --watchAll",
"//":"监听变化文件",
"test": "jest --watch",
}

jest –watch 运行后的配置说明

  • f 只运行失败的测试
  • o 只运行有改动的测试
  • p 只运行正则匹配到的文件名的测试
  • t 只运行正则匹配到的名字的测试,test(‘名字’)

基本测试

异步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
test('异步', () => {
return expect(featch().resolves.toMatchObject({
data: {
code: 1000
}
}))
})

test('异步', () => {
return expect(featch().rejects.toThrow())
})

test('异步', async () => {
await expect(featch().rejects.toThrow())
})

钩子函数

  • 先执行 desribe 内部的 beforeEach ,再执行外部的 beforeEach
  • desribe 内直接写的方法会先执行,然后再执行 beforeEach

Mock

https://jestjs.io/docs/en/mock-function-api#mockfnmockresolvedvaluevalue

异步函数的Moke

1
2
3
4
5
6
7
8
jest.moke('axios')

test.only('测试异步', async () => {
axios.get.mokeResolvedValue({ data: 'hello' })
await getData().then(data => {
expect(data.tobe('hello'))
})
})

模拟异步请求,不模拟axios

moke.js

1
2
3
4
// 假设存在异步请求模块
export const fetch = () => {
return axios.get()
}

test.js

1
2
3
4
5
6
7
8
jest.mock('./demo')
import { fetch } from './moke'

test('featch', () => {
return fetch().then(data => {
expect(data).toEqual('xxx')
})
})

换个写法 jest.config.js

1
2
3
module.exports = {
automock: true
}

这样就不需要手动 jest.mock('./demo')
如果 demo.js 还有同步函数引入而不需要Mock
const getNumber = jest.requireActual(./demo)

mock time 定时器模拟

https://jestjs.io/docs/zh-Hans/timer-mocks 无需等待3秒完成测试

timer.js

1
2
3
4
5
export const timer = callback => {
setTimeout(() => {
callback
}, 3000)
}

test.js

1
2
3
4
5
6
7
8
9
jest.useFakeTimers()

test('timer', () => {
const fn = jest.fn()
timer(fn)
jest.runAllTimers()
// 执行过一次
expect(fn).toHaveBeenCalledTimes(1)
})

类模拟

第二个参数可以自定义模拟类里的方法

1
2
3
4
5
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});

snapshot

配置文件是参数有 date 的话,每次运行值不一样

1
2
3
4
5
test('测试 date ', () => {
expect(config()).toMatchSnapshot({
time: expect.any(Date) // Number也可以
})
})

更新配置 npm i jest -g , jest -- -unpm run test -- -u ``

TDD 测试驱动开发

步骤

  1. 编写测试用例
  2. 运行测试,测试用例无法通过
  3. 编写代码,使测试用例通过测试
  4. 优化代码,完成开发
  5. 重复上述步骤

优势

  1. 长期减少回归BUG
  2. 代码质量更好 (组织,可维护)
  3. 测试覆盖率高
  4. 错误测试代码不容易出现

适合场景

  1. 业务逻辑和测试高耦合,业务变更时,测试代码也要变,所以不适合业务逻辑
  2. 函数工具类代码与TDD配合更好

特点

  1. 先写测试在写代码
  2. 一般结合单测使用,白盒
  3. 测试重点在代码
  4. 速度快 (不加载其他组件)
  5. 多组件拼装后不一定能通过集成

单元测试

优势

  1. 测试覆盖率高
  2. 业务耦合度高

缺点

  1. 代码量大
  2. 过于独立

适合场景

TDD + 单元测试 对工具库、组件库测试

BDD 行为驱动开发

先写代码再测试

优势

特点

  1. 先写代码再写测试
  2. 一般结合集成测试用,黑盒
  3. 测试重点在 UI(DOM)
  4. 速度慢 (要加载其他组件)

前端自动化测试

  1. 测试与业务解耦
  2. 代码测试覆盖率高不代表一定靠谱
  3. 测试越独立,隐藏的问题越多
  4. 隔段时间爬取线上数据做Mock测试数据

Vue 下的配置方式

package.json

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
"scripts": {
"test": "vue-cli-service test:unit",
"test:watch": "vue-cli-service test:unit --watch",
"test:coverage": "vue-cli-service test:unit --coverage"
},
"jest": {
"//": "引入模块如果没有后缀,则根据下面规则匹配后缀",
"moduleFileExtensions": [
"js",
"jsx",
"json",
"vue"
],

"//": "文件转换插件",
"transform": {
"^.+\\.vue$": "vue-jest",

"//": "遇到图片转为字符串即可",
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": "jest-transform-stub",
"^.+\\.jsx?$": "babel-jest"
},

"//": "不查找哪些文件夹",
"transformIgnorePatterns": [
"/node_modules/"
],

"//": "@别名",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1",
"^@u/(.*)$": "<rootDir>/src/utils/$1"
},

"//": "快照格式化",
"snapshotSerializers": [
"jest-serializer-vue"
],

"//": "找测试文件",
"testMatch": [
"**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
],

"//": "jsDOM页面的地址",
"testURL": "http://localhost/",

"//": "两个插件,交互式选择等等",
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
}

真实测试例子

以下测试为实际使用到的测试用例
Vue TodoList 测试项目

https://github.com/DougFlands/JEST-Vue

userAgent 的修改

项目中会判断UA,也会打印 log 所以要先设置

1
2
3
4
Object.defineProperty(navigator, 'userAgent', {
writable: true,
value: 'MicroMessenger/6.7.3'
})

测试模板

测试文件模板,注入 Vuex \ VueRouter

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
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router'
import cardDetail from '@/views/card_detail.vue';
import Vuex from 'vuex'

const localVue = createLocalVue()

// router不注入到LocalVue,否则无法mock
const router = new VueRouter()

describe('cardDetail', () => {
let actions = {
getClientInfo: jest.fn(),
getBalanceInfo: jest.fn(),
balanceInfo: jest.fn(),
createJSPay: jest.fn(),
}

let mutations = {
setClientID: jest.fn()
}

let store = new Vuex.Store({
state: {
clientID: '123',
},
mutations,
actions
})

let wrapper = mount(cardDetail, {
localVue,
store,
router,
mocks: {
$route: {
query: { clientID: '1233' }
}
}
})
})


测试用例

用例1

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
test('测试组件点击', () => {
const checkboxData = [
{
price1: 1,
price2: 2,
checkboxInd: 0,
},
{
price1: 2,
price2: 3,
checkboxInd: 1,
},
]

// 传值给 props
const wrapper = mount(priceCheckbox, {
localVue,
propsData: {
checkboxData
}
})

// 点击事件,触发函数
wrapper.vm.handleSelecet(checkboxData[0])

// handleSelecet 会设置值,对值进行校验
expect(wrapper.vm.selectItmeIndex).toBe(checkboxData[0].checkboxInd)
})

用例2

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
test('clientid 不同则重新获取, 测试 created 阶段', () => {
shallowMount(cardDetail, {
localVue,
store,
router,
mocks: {
$route: {
query: { clientID: '1233' }
}
}
})
expect(mutations.setClientID).toHaveBeenCalled()
})


test('调用充值接口', () => {
let message = ''

// 页面中用了 $message 提示弹窗,这里验证传参
wrapper.vm.$message = jest.fn(val => {
message = val
})
wrapper.vm.balance = ''
wrapper.vm.handleRecharge()

// 验证 balance 这个参数是否符合预期
expect(message).toBe('请输入充值金额')
})

用例3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
test('缺少card_id则显示卡片不存在', () => {
const wrapperNoId = mount(bindCard, {
router,
mocks: {
$route: {
query: { card_id: 1 }
}
}
})
const tips = wrapperNoId.find('.tips');

// 测试显示的消息
expect(tips.text()).toEqual('此卡片不存在');
});

test('bindCard 有且只 call 了一次', () => {
wrapper.find('.btn').trigger('click')
expect(actions.cardBind).toHaveBeenCalled()
});

用例4

1
2
3
4
5
6
it('测试count组件能否正常显示并增加', () => {
const wrapper = mount(HelloWorld, {localVue})
expect(wrapper.vm.count).toBe(0)
wrapper.find('#add').trigger('click')
expect(wrapper.vm.count).toBe(1)
})

用例5 (使用 ElementUI)

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
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import adminList from '@/views/admin/list';
import ElementUI from 'element-ui';
import Api from '../../src/api'
import VueRouter from 'vue-router'

const localVue = createLocalVue();
localVue.use(ElementUI);
localVue.use(VueRouter)

// 对 axios 发送请求的函数挂载
localVue.prototype.$api = Api

const router = new VueRouter()

jest.mock('../../src/api')
Api.test.getList.mockResolvedValue({
"total": 4,
"data": [
{
"id": 1,
"title": "第一个",
"page": 1
},
{
"id": 2,
"title": "第二个",
"page": 1
},
{
"id": 3,
"title": "第三个",
"page": 1
},
{
"id": 4,
"title": "第四个",
"page": 1
}
]
});

describe('admin list', () => {
const wrapper = mount(adminList, {
localVue,
router,
mocks: {
$route: {
params: { id: 1 }
}
}
})

test('getUserInfo 有且只 call 了一次', () => {
expect(Api.test.getList.mock.calls.length).toBe(1);
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.status.total).toBe(4)
})
});

test('获取列表按钮', () => {
wrapper.find('#getList').trigger('click')

wrapper.vm.$nextTick(() => {
expect(wrapper.vm.status.total).toBe(4)
})
})

test('清空按钮', () => {
wrapper.find('#clearSearch').trigger('click')

wrapper.vm.$nextTick(() => {
expect(wrapper.vm.search.title).toBe('')
expect(wrapper.vm.search.pagenum).toBe(1)
})
})

test('打开弹窗', () => {
wrapper.find('#openDialog').trigger('click')
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.status.dialog).toBe(true)
})
})
})

React 下配置

脚手架配置

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
"jest": {
"roots": [
"<rootDir>/src"
],
// 代码覆盖率测试哪些文件
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
// 启动时加载的项目,下面这个是解决兼容性包
"setupFiles": [
"react-app-polyfill/jsdom"
],
// 启动jest 的文件
"setupFilesAfterEnv": [
"<rootDir>/src/setupTests.js",
"./node_modules/jest-enzyme/lib/index.js"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
// 测试运行环境
"testEnvironment": "jest-environment-jsdom-fourteen",
// 引入文件时的处理
"transform": {
"^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
// 忽略上面的转换
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
"^.+\\.module\\.(css|sass|scss)$"
],
// 引入模块默认为 modules ,这里可以增加
"modulePaths": [],
// css modules 配置
"moduleNameMapper": {
"^react-native$": "react-native-web",
"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
},
// 引入时不写文件类型,自动搜索以下的
"moduleFileExtensions": [
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
"node"
],
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
},

Enzyme

Airbnb出的测试框架

https://airbnb.io/enzyme/
https://github.com/FormidableLabs/enzyme-matchers/tree/master/packages/jest-enzyme yarn add enzyme enzyme-adapter-react-16 jest-enzyme @types/jest -D

代码覆盖率

1
2
3
4
5
6
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js",
"coverage": "node scripts/test.js --coverage --watchAll=false",
},

项目

https://github.com/DougFlands/JEST-react