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) }) })
|
更新配置 npm i jest -g
, jest -- -u
或 npm run test -- -u
``
TDD 测试驱动开发
步骤
- 编写测试用例
- 运行测试,测试用例无法通过
- 编写代码,使测试用例通过测试
- 优化代码,完成开发
- 重复上述步骤
优势
- 长期减少回归BUG
- 代码质量更好 (组织,可维护)
- 测试覆盖率高
- 错误测试代码不容易出现
适合场景
- 业务逻辑和测试高耦合,业务变更时,测试代码也要变,所以不适合业务逻辑
- 函数工具类代码与TDD配合更好
特点
- 先写测试在写代码
- 一般结合单测使用,白盒
- 测试重点在代码
- 速度快 (不加载其他组件)
- 多组件拼装后不一定能通过集成
单元测试
优势
- 测试覆盖率高
- 业务耦合度高
缺点
- 代码量大
- 过于独立
适合场景
TDD + 单元测试 对工具库、组件库测试
BDD 行为驱动开发
先写代码再测试
优势
特点
- 先写代码再写测试
- 一般结合集成测试用,黑盒
- 测试重点在 UI(DOM)
- 速度慢 (要加载其他组件)
前端自动化测试
- 测试与业务解耦
- 代码测试覆盖率高不代表一定靠谱
- 测试越独立,隐藏的问题越多
- 隔段时间爬取线上数据做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()
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, }, ]
const wrapper = mount(priceCheckbox, { localVue, propsData: { checkboxData } })
wrapper.vm.handleSelecet(checkboxData[0])
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 = ''
wrapper.vm.$message = jest.fn(val => { message = val }) wrapper.vm.balance = '' wrapper.vm.handleRecharge()
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)
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" ], "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)$" ], "modulePaths": [], "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