持续集成是互联网软件开发上线流程中的核心一环,自动化测试是持续集成得以实现的核心步骤,缺乏了自动化测试,持续集成自然无从谈起。
在日常的开发中,前端错综复杂的变化引发的 bug 往往令开发者头疼,或多或少经历过 修完东墙西墙倒 的经历,此时前端自动化测试就显得非常重要。
前端的自动化测试无非也是编写测试用例,在持续集成时执行跑通全部测试用例。如果是一个短平快的小项目,引入前端自动化测试,编写测试用例,无疑只会增加开发成本,然而当项目扩大、迭代频繁、逻辑复杂、需求反复变更的情况下,回归测试的成本是巨额的,自动化测试的优势就能体现出来。
自动化测试的收益 = 迭代次数 * 全手动执行成本 - 首次自动化成本 - 维护次数 * 维护成本
尽早引入前端自动化测试不仅能够减少项目 bug 出现概率 (尤其是回归测试中的 bug),还能更好地进行代码组织,增强项目的可维护性,尤其对于工程质量较差的项目,收益是巨大的;如果将其应用于持续集成中,commit 触发自动执行测试脚本,还能大幅提升团队的开发效率。
单元测试
单元测试,见名知意,可以理解为对系统的某个单元进行测试,而这个单元,可以是某个函数,某个组件,对于这种测试形式来说,我们只关注这个独立的单元的功能是否正常。测试用例以当前单元内的功能作为对象。
集成测试
将多个单元集成到一起,进行测试,重点关注各个单元串联起来之后的系统整体功能是否正常。此时的测试用例以多个单元组成的某个独立的系统为对象。
前端自动化测试可以按照开发模式分两类:TDD (Test-Driven Development) 测试驱动开发、BDD (Behavior Driven Development) 行为驱动开发。
测试还可以按照用例粒度分为 单元测试 (Unit Test)、集成测试 (Integration Test)、端到端测试 (End to End Test)。
TDD 顾名思义,开发者根据需求先编写测试用例,再逐步开发,最终满足全部测试用例的需求。
TDD 的基本思路就是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析,设计,质量控制量化的过程。
TDD 的重要目的不仅仅是测试软件,测试工作保证代码质量仅仅是其中一部分,而且是在开发过程中帮助客户和程序员去除模棱两可的需求。TDD 首先考虑使用需求(对象、功能、过程、接口等),主要是编写测试用例框架对功能的过程和接口进行设计,而测试框架可以持续进行验证。
刚开始的时候,只有测试用例,未进行功能开发,执行测试用例,满屏是红色的测试用例不通过提示,随着测试用例被满足变绿,最终全部变绿,功能开发完成,因此前端自动化测试也被叫做 Red-Green Development。
TDD 先写测试再写代码,单位是模块,多用于 单元测试
重点在测试代码,属于 白盒测试
测试内容是模块,速度快,但是忽略模块间依赖,安全感低 流程
- 编写测试用例
- 运行测试,测试用例无法通过测试
- 编写代码,时测试用例通过测试
- 优化代码,完成开发
- 重复上述步骤
优势
- 长期减少回归 bug
- 代码质量更好(组织,可维护性)
- 测试覆盖率高
- 错误测试代码不容易出现
测试用例模拟用户的操作行为,通常在完成业务代码开发之后,以用户的操作为指导编写测试代码。当测试用例跑通之后,就可以认为系统的整体流程已经流畅。
BDD 的模式适用于平时的业务代码开发,因为业务的需求有可能变更频繁,但操作流程有可能不会变化,当业务代码发生变化的时候,可以使用原来的测试用例继续跑代码,节省了开发时间。
BDD 先写代码再写测试,测试单位是功能,多用于集成测试
重点在测试 UI(DOM)功能,属于黑盒测试
测试内容是整套操作流程,速度慢,往往需要多个模块配合,安全感高
TDD 开发模式更适用于开发,类似方法函数库,对于数据的处理,对于这种显示组件,更加推荐于BDD 的开发方式,这样既有了测试,也不会增加过多的工作负担,
在平时的项目中,通常使用 TDD 和 BDD 相结合来进行测试,TDD 负责方法类、独立组件的测试。BDD 则负责整体业务模块的测试。
前端近几年涌现出很多优秀的测试工具:
karma – Google Angular 团队开发的测试运行平台,配置简单灵活,能够很方便在多个真实浏览器中运行测试
mocha – 很优秀的测试框架,有完善的生态系统,简单的测试组织方式,不对断言库和工具做任何限制,非常灵活
jest – facebook 出品的大而全的测试框架,React 官方推荐的单元测试框架,配置简单运行速度快
还有很多其他的前端测试框架,但大同小异,无非是对断言和测试桩等工具的集成度不同,论成熟度首推 mocha,论效率首推 jest。
jest 是 Facebook 开源的 JavaScript 测试框架,它自动集成了断言、JsDom、覆盖率报告等开发者所需要的所有测试工具,是一款几乎零配置的测试框架,而且速度很快,此处选择 jest 作为测试工具。
- Jest测试框架优点
比较新:喜新厌旧是人的天性,作为一个程序员,你更要有拥抱全新知识的态度。绝不能固步自封,顽固不化。
基础很好:框架基础好就是性能好、功能多、简单易用,Jest在这三个方面你可以完全放心。
速度快: 单独模块测试功能,比如说有两个模块A和B,以前都测试过了,这时候你只改动A模块,再次测试,模块B不会再跑一次,而是直接测试A模块。
API
简单 :等你基础知识学完后,你就会发现API
非常简单,数量也少。 隔离性好:Jest里会有很多的测试文件等待我们使用,Jest的执行环境都是隔离,这样就避免不同的测试文件执行的时候互相影响而造成出错。
IDE
整合:Jest直接可以和很多编辑器(VSCode)
进行融合,让测试变的更加简单。 多项目并行:比如我们写了
Node.js
的后台项目,用React写了一个前台项目,Jest是支持他们并行运行,让我们的效率更加提高了。 快出覆盖率:(测试代码覆盖率) 对于一个项目的测试都要出覆盖率的,Jest就可以快速出这样的覆盖率统计结果,非常好用。
在 jest 单元测试中使用快照、API-mock 和 DOM 样式状态断言已经能够实现基础的 UI 测试,但是单元测试属于白盒测试,更关注数据的流动,而端到端测试 (End To End Test) 属于黑盒测试,更关注操作结果的展示,因此测试效果自然不同。端到端测试更贴近真实用户操作,页面运行在真实的浏览器环境中,因此端到端测试是从用户角度出发的测试。
端到端测试的工具也有不少,最为突出的是老牌 e2e 测试工具 NightWatch,根据需要安装 Selenium 或其他 Webdriver,优势是可以测试多类浏览器,兼容性好,而 Cypress 是为现代网络打造的下一代前端测试工具,安装更简单,可以测试任何在浏览器中运行的内容,测试执行效率更高,此处选用 Cypress 作为端到端测试工具。
就像官网所说,Cypress 就像一个完整的烘烤箱,他还自带电池,下面是一些其它测试框架无法做到的事情:
时间旅行: Cypress 在你运行测试的时候拍摄快照。 只要将鼠标悬停在 命令日志 上就能够清楚的了解到每一步发生了什么。
可调式能力: 你再也不需要去猜测测试为什么失败了。 调试工具 和 Chrome 的调试工具差不多。 清晰的错误原因和堆栈跟踪让调试能够更加快速。
自动等待: 在你的测试中不再需要添加等待或睡眠函数了。在执行下一条命令或断言前 Cypress 会 自动等待 异步将不再是问题.
Spies, Stubs, and Clocks: 验证和 控制 函数、服务器响应或者计时器的行为。你喜欢的单元测试的功能都掌握在你的手中。
网络流量控制: 非常容易的进行 控制、保存和边缘测试,而这并不需要涉及到你的服务。你可以根据需要保留网络流量。
一致的结果: 架构不需要 Selenium 或者 WebDriver。向快速,一致和可靠的无侵入测试看齐。
屏幕截图和视频: 可以查看测试失败时候系统自动截取的图片,或者整个测试的录制视频。
安装非常简单:
$ npm install cypress --save-dev
配置文件修改:
// baseUrl: "http://localhost:8080", // 测试域名
fixturesFolder: 'tests/e2e/fixtures', // 外部静态数据,如网络请求或存放模拟上传或读取的文件
integrationFolder: 'tests/e2e/specs', // 测试用例文件夹
screenshotsFolder: 'tests/e2e/screenshots', // 屏幕快照
// videoRecording: true,
videosFolder: 'tests/e2e/videos', // 录制后的文件夹
supportFile: 'tests/e2e/support/index.js', // 配置自定义命令全局注入
viewportHeight: 768, // 测试浏览器视口高度
viewportWidth: 1366 // 测试浏览器视口宽度
然后,可以将命令写到 package.json 中,如果使用 vue-cli,可以看到已经存在 “test:e2e”: “vue-cli-service test:e2e”,直接执行即可启动测试,在这之前需要先启动项目和 mock 服务。
$ npm run test:e2e
在 Cypress 中有两种断言写法:
隐式: 使用 .should() 或者 .and(),.and() 只是 .should() 的别名,它链接多个断言使代码更易读
显式: 使用 expect
// 隐式
cy.get('#header a')
.should('have.class', 'active')
.and('have.attr', 'href', '/users')
// 显式
cy.get('tbody tr:first').should(($tr) => {
expect($tr).to.have.class('active')
expect($tr).to.have.attr('href', '/users')
})
// 常用断言
cy.get(':checkbox').should('be.disabled')
cy.get('form').should('have.class', 'form-horizontal')
cy.get('input').should('not.have.value', 'US')
cy.request('/users/1').its('body').should('deep.eq', { name: 'Jane' })
// 默认断言
/*
cy.visit() 预期这个页面是状态为200的 text/html内容页
cy.request() 预期远程服务器存在并提供响应
cy.contains() 预期包含内容的元素最终存在于DOM中
cy.get() 预期元素最终存在于 DOM中
.find() 预期元素最终存在于 DOM 中
.type() 预期元素最终为 可输入 状态
.click() 预期元素最终为 可操作 状态
.its() 预期最终找到当前主题的一个属性
屏幕录制截屏是 Cypress 的一大特色,在 Test Runner 中单击项目的 Runs 选项卡,登录账号,再根据提示执行指令,即可完成屏幕录制和自动截屏。
$ ./node_modules/cypress/bin/cypress run --record --key xxxxxxxx
还可以在用例中主动截屏,存储在 screenshots 目录下。
jest 需要自动运行测试脚本,node 环境是必不可少的,如果从头搭建,首先得初始化项目 package.json 并安装 jest:
$ npm init
$ npm install jest -D
jest 默认不支持 es6,需要使用 babel 来支持 es6,安装 babel:
$ npm install @babel/core @babel/preset-env -D
配置 babel,修改 .babelrc 文件:
{
"presets": [
["@babel/preset-env", {
"targets": {
"node": "current"
}
}]
]
}
现实项目中,往往不会从零搭建 jest 项目,更多的情况是,需要在一个脚手架已经搭建好的项目中引入自动化测试,此处在 vue-cli 基础上修改 jest 配置,安装好 jest 后需要修改项目根目录下的配置文件 jest.config.js,重点关注 testMatch 和 testPathIgnorePatterns 两个属性,testMatch 指定了匹配的测试用例文件的路径,而 testPathIgnorePatterns 则可以忽略指定文件,因此使用两个属性可以精确匹配到项目中所有的测试用例。
module.exports = {
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'
},
collectCoverageFrom: ['**/*.{vue}', '!**/node_modules/**'],
transformIgnorePatterns: [
'/node_modules/'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
snapshotSerializers: [
'jest-serializer-vue'
],
testMatch: [
'**/__tests__/unit/*.test.(js|jsx|ts|tsx)'
],
testPathIgnorePatterns: [
'/.eslintrc/.js'
],
testURL: 'http://localhost/',
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname'
]
}
最后还需要在 package.json 中添加测试指令
{
"test:unit": "vue-cli-service test:unit --watch"
}
执行对应指令即可在项目中执行测试
$ npm run test:unit
项目目录结构
项目的目录结构组织如下:
├── src
│ ├── assets
│ ├── containers
│ │ └── TodoList
│ │ ├── __mocks__ 测试mocks文件
│ │ ├── __tests__ 测试用例文件
│ │ │ ├── unit 单元测试
│ │ │ │ └── TodoList.test.js
│ │ │ └── integration 集成测试
│ │ │ └── store.test.js
│ │ ├── components 子组件
│ │ │ ├── Header.vue
│ │ │ └── UndoList.vue
│ │ │
│ │ └── TodoList.vue TodoList父vue组件
│ │
│ ├── utils
│ │ └── testUtils.js 存放测试工具公共工具
│ ├── App.vue vue-App
│ └── main.js 入口文件
│
├── public
├── jest.config.js jest配置文件
├── ...
└── package.json
待测试文件: hello.js
测试脚本文件取名:hello.test.js
orhello.spec.js
测试目录:tests
or__tests__
test("测试用列描述信息",()=>{
})
// or
it("测试用例描述信息",()=>{
})
测试即运行结果是否与我们预期结果一致 断言函数用来验证结果是否正确
exspect(运行结果).toBe(期望的结果);
//常见断言方法
expect({a:1}).toBe({a:1})//判断两个对象是否相等
expect(1).not.toBe(2)//判断不等
expect({ a: 1, foo: { b: 2 } }).toEqual({ a: 1, foo: { b: 2 } })
expect(n).toBeNull(); //判断是否为null
expect(n).toBeUndefined(); //判断是否为undefined
expect(n).toBeDefined(); //判断结果与toBeUndefined相反
expect(n).toBeTruthy(); //判断结果为true
expect(n).toBeFalsy(); //判断结果为false
expect(value).toBeGreaterThan(3); //大于3
expect(value).toBeGreaterThanOrEqual(3.5); //大于等于3.5
expect(value).toBeLessThan(5); //小于5
expect(value).toBeLessThanOrEqual(4.5); //小于等于4.5
expect(value).toBeCloseTo(0.3); // 浮点数判断相等
expect('Christoph').toMatch(/stop/); //正则表达式判断
expect(['one','two']).toContain('one'); //不解释
describe("关于每个功能或某个组件的单元测试",()=>{
// 不同用例的单元测试
})
{
"nocache": "jest --no-cache", //清除缓存
"watch": "jest --watchAll", //实时监听
"coverage": "jest --coverage", //生成覆盖测试文档
"verbose": "npx jest --verbose" //显示测试描述
}
beforeAll:在所有测试用例执行前调用
afterAll:在所有测试用例执行后调用
beforeEach:在每一个测试用例执行前调用
afterEach:在每一个测试用例执行后调用
describe: 分组测试
每个describe都有自己的作用域且互不影响。
对于其执行的逻辑一定要放在钩子函数中
生成mock函数
const func = jest.fn()
mock 函数作用
1、 捕获函数的调用和返回结果,以及this和调用顺序
2、 可以让我们自由的设置返回结果
3、 改变函数的内部实现
API:
mockReturnValue
mockReturnValueOnce
mockImplementation
mockImplementationOnce
mockResolvedValue
mockResolvedValueOnce
mockReturnThis
jest.mock 发现 util 是一个类,会自动把类的构造函数和方法变成 jest.fn()
mock的输出格式
对于单元测试,外部 class 的实现无需关心,使用 jest.fn
生成一个 mock 类,例如测试 mock.js
输出的 mock 为:
{
calls: [ [ 123 ], [ 123 ], [ 123 ] ],
instances: [ undefined, undefined, undefined ],
invocationCallOrder: [ 1, 2, 3 ],
results: [
{ type: 'return', value: 456 },
{ type: 'return', value: 789 },
{ type: 'return', value: undefined }
]
}
describe('对象测试', () => {
it("是否同一个对象", () => {
const foo = { a: 1 }
expect(foo).toBe(foo)
})
it("对象值是否相等", () => {
expect({ a: 1, foo: { b: 2 } }).toEqual({ a: 1, foo: { b: 2 } })
})
test('对象赋值', () => {
const data = { one: 1 };
data['two'] = 2;
expect(data).toEqual({ one: 1, two: 2 });
});
});
异步测试脚本执行完,单元测试就结束了,如果需要延时才能断言的结果,单元测试函数需要设置 done
形参,在定时回调函数中调用,显示的通过单元测试已完成。
describe('异步操作测试', () => {
function foo(callback) {
console.log('foo...')
setTimeout(() => {
callback && callback();
}, 1000)
}
it('异步测试', (done) => {
function bar() {
console.log('bar..')
done();
}
foo(bar);
});
});
基于 jest 提供的两个方法 jest.useFakeTimers
和 jest.runAllTimers
可以更优雅的对延时功能的测试。
jest.useFakeTimers()避免定时器等待时间
jest.runAllTimers() 执行所有的timers
jest.runOnlyPendingTimers() 只执行当前队列中的timers
jest.advanceTimersByTime(msToRun) 时间快进多少
describe('定时器相关测试', () => {
// 开启定时函数模拟
jest.useFakeTimers();
function foo(callback) {
console.log('foo...')
setTimeout(() => {
callback && callback();
}, 1000)
}
it('断言异步测试', () => {
//创建mock函数,用于断言函数被执行或是执行次数的判断
const callback = jest.fn();
foo(callback);
expect(callback).not.toBeCalled();
//快进,使所有定时器回调
jest.runAllTimers();
expect(callback).toBeCalled();
})
});
测试 DOM 要记得把测试环境设为浏览器环境,jest 在底层模拟了一套 dom api
jest 提供了一套 node 环境下的 dom,在获取到指定的 dom 元素后,可以对 dom 元素执行各种操作:
const input = findTestWrapper(wrapper, ‘input’)
input.exists() // 获取dom存在性
input.setValue(‘csxiaoyao’) // 给dom赋值
input.trigger(‘keyup.enter’) // 触发dom方法
input.trigger(‘change’) // 触发dom方法
实现 dom 渲染测试,以及点击事件等交互功能测试。
describe('Dom测试', () => {
it('测试按钮是否被渲染 ', () => {
document.body.innerHTML = `
<div>
<button id='btn'>小按钮</button>
</div> `
console.log(document.getElementById('btn'), document.getElementById('btn').toString())
expect(document.getElementById('btn')).not.toBeNull();
expect(document.getElementById('btn').toString()).toBe("[object HTMLButtonElement]");
});
it('测试点击事件', () => {
const onclick = jest.fn();
document.body.innerHTML = `
<div>
<button id='btn'>小按钮</button>
</div> `
const btn = document.getElementById('btn');
expect(onclick).not.toBeCalled();
btn.onclick = onclick;
btn.click();
expect(onclick).toBeCalled();
expect(onclick).toHaveBeenCalledTimes(1);
btn.click();
btn.click();
expect(onclick).toHaveBeenCalledTimes(3);
});
});
toMatchSnapshot 生成快照文件夹__snapshots__
toMatchInlineSnapshot 行内快照
对于变化的配置量 Snapshot({ time2: expect.any(Date)})
同样可以接收 Date | String | Number
test('should generateAnotherConfig 函数', () => {
expect(generateAnotherConfig()).toMatchSnapshot({
time2: expect.any(Date)
})
})
如果创建的项目没有安装 unit-jest 依赖包,可以通过 vue add @vue/unit-jest
命令添加。否则通过脚手架手动模式创建一个包含 unit-jest 的项目。
import { mount, shallowMount } from '@vue/test-utils’
不同的是,mount 方法会渲染完整的组件,包括子组件,适合 BDD 和集成测试,而 shallowMount 方法只会渲染当前组件,因此速度更快,效率更高,更加适合 TDD 和单元测试。
mount 和 shallowMount 的区别 - shallowMount 只挂载指定组件,不挂载子组件 - mount 挂载所有组件
为了方便获取测试需要的 DOM 元素,可以将获取 DOM 元素的方法进行封装,在 testUtils.js 中定义 findTestWrapper 方法如下:
export const findTestWrapper = (wrapper, tag) => {
return wrapper.find(`[data-test="${tag}"]`)
}
Vue 的渲染机制 默认情况下 Vue 会异步地批量执行更新 (在下一轮 tick),以避免不必要的 DOM 重绘或者是观察者计算
异步测试需要在 nextTick () 之后执行
vue 组件渲染测试
it('挂载countBtn组件', () => {
const wraper = shallowMount(CountBtn);
const btn = wraper.find("button");
expect(wraper.html()).toBe(`<button>点击次数0</button>`);
});
大部分的自动化测试,都是通过 vm 实例上的 data 变化来测试的,可以获取对应的 data 值,也可以通过 vm 调用相关方法。
wrapper.vm. d a t a . i n p u t V a l u e / / 获 取 i n p u t V a l u e w r a p p e r . v m . a d d U n d o I t e m ( ′ c s x i a o y a o ′ ) / / 执 行 a d d U n d o I t e m 方 法 w r a p p e r . v m . data.inputValue // 获取 inputValue wrapper.vm.addUndoItem('csxiaoyao') // 执行 addUndoItem 方法 wrapper.vm. data.inputValue//获取inputValuewrapper.vm.addUndoItem(′csxiaoyao′)//执行addUndoItem方法wrapper.vm.emit(‘add’, content) // 触发外部 add 方法
vue 组件点击事件测试
it('测试countBtn组件点击', (done) => {
const wraper = shallowMount(CountBtn);
const btn = wraper.find("button");
expect(wraper.html()).toBe(`<button>点击次数0</button>`);
btn.trigger('click');
setTimeout(() => {
expect(wraper.html()).toBe(`<button>点击次数1</button>`);
done();
}, 1000);
});
it('优雅的测试点击事件', async () => {
const wraper = shallowMount(CountBtn);
const btn = wraper.find("button");
expect(wraper.html()).toBe(`<button>点击次数0</button>`);
btn.trigger('click');
await wraper.vm.$nextTick();
expect(wraper.html()).toBe(`<button>点击次数1</button>`);
});
定时器测试,可以借助 vm.$nextTick 方法和 jest 定时器操作实现
beforeEach(() => {
jest.useFakeTimers()
})
it(`
1. 用户进入页面时,等待 3s
2. 列表应该展示远程返回的数据
`, (done) => {
const wrapper = mount(TodoList, { store }) // 传入 store
jest.runAllTimers()
wrapper.vm.$nextTick(() => {
const listItems = findTestWrapper(wrapper, 'list-item')
// 不能直接判断,因为异步操作在 mounted 之后
expect(listItems.length).toBe(2)
done()
})
})
本地去模拟
axios
请求实现方法
1.直接 使用创建的
__mock__
模拟2.打开
jest.config.js
Line 6 automock: true
模拟异步请示,测试渲染结果是否一致
<!-- User.vue -->
<template>
<table>
<tr v-for="item in list" :key="item.id">
<td>{{item.id}}</td>
<td>{{item.name}}</td>
<td>{{item.age}}</td>
</tr>
</table>
</template>
<script>
export default {
data() {
return {
list: []
}
},
created() {
this.$http.get('/user').then(({
data
}) => {
this.list = data
})
}
}
</script>
// User.spec.js
import { mount } from '@vue/test-utils';
import User from '@/components/User';
it('测试用户组件', async() => {
const wrapper = mount(User,{
mocks:{
$http:{
get: url=>Promise.resolve({data:[{id:1,name:'xxxx',age:18},{id:2,name:'yyyy',age:19}]})
}
}
})
console.log(wrapper.html())
// 渲染前
expect(wrapper.html()).toBe('<table></table>');
//对于异步请求,可以使用 vue 的 vm.$nextTick 方法实现异步数据的渲染。
await wrapper.vm.$nextTick();
// 渲染后
// console.log(wrapper.html())
// console.log(wrapper.find('tr'))
expect(wrapper.findAll('tr').length).toBe(2)
expect(wrapper.findAll('td').at(2).html()).toBe('<td>18</td>')
});