前端测试简介
黑盒&白盒
- 黑盒测试一般也被称为功能测试,黑盒测试要求测试人员将程序看作一个整体,不考虑其内部结构和特性,只是按照期望验证程序是否能正常工作
- 白盒测试是基于代码本身的测试,一般指对代码逻辑结构的测试。
测试分类
单元测试(Unit Testing)
单元测试是指对程序中最小可测试单元进行的测试,例如测试一个函数,一个模块,一个组件..
集成测试(Integration Testing)
将已测试过的单元测试函数进行组合集成暴露出的高层函数或类的封装,对这些函数或类进行的测试
端到端测试(E2E Testing)
模拟用户的行为,例如点击,输入等操作。然后观察页面中的元素展现形式是否和预期一致。以此来判断是否通过测试
TDD&BDD
TDD是测试驱动开发(Test-Driven Development)
TDD的原理是在开发功能,业务代码之前,先编写单元测试用例代码
BDD是行为驱动开发(Behavior-Driven Development)
开发者、测试人员一起合作,分析软件的需求,然后将这些需求写成一个个的故事。开发者负责填充这些故事的内容,保证程序实现效果与用户需求一致。
小结
TDD是先写测试再开发 (一般都是单元测试,白盒测试);而BDD则是按照用户的行为来开发,再根据用户的行为编写测试用例 (一般都是集成测试,黑盒测试)
为什么选择 Jest
自动化测试的执行通常需要测试规范、断言、mock、覆盖率工具等支持,上述工具在繁荣的 Node.js 生态中有很多优秀实现
- 测试框架:提供一些方便的语法来描述测试用例,常见的测试框架有
Jasmine,Mocha,Jest - 断言库:提供语义化方法,用于对参与测试的值做各种各样的判断。这些语义化方法会返回测试的结果,要么成功、要么失败。常见的断言库有
Should.js,Chai.js等。 - 测试覆盖率工具:用于统计测试用例对代码的测试情况,生成相应的报表,比如
istanbul。 但组合起来使用会带来两个问题 - 多种工具的选择和学习有一定的成本
- 把多个工具组合成特定测试解决方案的配置复杂
Jest 是 Facebook 出品的一个测试框架,相对其他测试框架,其有几大特点:
- 内置了常用的测试工具,比如自带断言、Mock、测试覆盖率工具,实现了开箱即用。
- 作为一个面向前端的测试框架, Jest 可以利用其特有的快照功能,通过比对 UI 代码生成的快照文件,实现对 Vue、React 等框架的自动测试。
- Jest 的测试用例是并行执行的,而且只执行发生改变的文件所对应的测试,测试速度快。
jest 安装
npm install jest --save-dev
在 package.json 中添加脚本配置
"scripts": {
"test": "jest"
}
这样就可以使用npm run test执行测试代码了
牛刀小试
创建目录
├── src
│ └── math.js
├── test
│ └── math.test.js
├── jest.config.js
└── package.json
创建src/math.js
export function add(a, b) {
return a + b;
}
创建test/math.test.js
import { add } from "./math";
test("add方法", () => {
expect(add(1, 2)).toBe(3);
});
其中expect(add(1, 2)).toBe(3)是一句断言,toBeJest 管它叫 matcher (匹配器)
执行测试命令
npm test
默认文件匹配规则 匹配 test 文件夹下的 .js 文件(.jsx .ts .tsx 也可以) 匹配所有后缀为 .test.js 或 .spec.js 的文件(.jsx .ts .tsx 也可以) 可以通过根目录下的 jest.config.js 文件自定义测试文件匹配规则
jest配置文件
根目录下的jest.config.js文件可以自定义jest的详细配置,jest的相关配置也可以在package.json内,为了可读性最好放在独立配置文件
已有的项目添加jest配置文件:
npx jest --init
jest命令行工具
异步模块的测试
jest对异步方法提供了几种测试支持
//async.js
export const fetchDataCallback = (fn) => {
axios.get("http://www.dell-lee.com/react/api/demo.json").then((res) => {
fn(res.data);
});
};
export const fetchData = () => {
return axios.get("http://www.dell-lee.com/react/api/demo.json");
};
回调
test 方法的第二个函数传入 done 可以用来标识回调执行完成
//async.test.js
//不要这样写!此测试就在没有调用回调函数前结束
test("fetchDataCallback", () => {
fetchDataCallback((res) => {
expect(res).toEqual({
success: true,
});
});
});
//正确写法
test("fetchDataCallback", (done) => {
fetchDataCallback((res) => {
try {
expect(res).toEqual({
success: true,
});
done();
} catch (err) {
done(err);
}
});
});
promise
test("fetchData", () => {
return fetchData().then((res) => {
expect(res.data).toEqual({
success: true,
});
});
});
一定要把 Promise 做为返回值,否则测试用例会在异步方法执行完之前结束,如果希望单独测试 resolve 可以使用另外一种书写方式
test("fetchData", () => {
return expect(fetchData()).resolves.toMatchObject({
data: {
success: true,
},
});
});
async/await
async/await 测试比较简单,只要外层方法声明为 async 即可
test("fetchData", async () => {
const res = await fetchData();
expect(res.data).toEqual({
success: true,
});
});
测试用例钩子函数
写测试用例的时候经常需要在运行测试前做一些预执行,和在运行测试后进行一些清理工作,Jest 提供辅助函数来处理这个问题
一次性设置
如果相关任务全局只需要执行一次,可以使用 beforeAll 和 afterAll
beforeAll(() => {
init()
});
afterAll(() => {
clear()
});
test("addOne,", () => {
counter.addOne();
expect(counter.number).toBe(1);
});
test("minusOne,", () => {
counter.minusOne();
expect(counter.number).toBe(-1);
});
多次重复
如果在每个测试任务开始前需要执行数据初始化工作、结束后执行数据清理工作,可以使用 beforeEach 和 afterEach
import Counter from "./hook";
let counter = null;
beforeEach(() => {
counter = new Counter();
});
afterEach(() => {
counter=null
});
test("addOne,", () => {
counter.addOne();
expect(counter.number).toBe(1);
});
test("minusOne,", () => {
counter.minusOne();
expect(counter.number).toBe(-1);
});
作用域
默认情况下,before 和 after 的块可以应用到文件中的每个测试。 可以通过 describe 块来将测试分组。 当 before 和 after 的块在 describe 块内部时,则其只适用于该 describe 块内的测试
describe("分组1", () => {
beforeAll(()=>{})
beforeEach(() => {});
afterRAch(()=>{})
afterAll(()=>{})
test("addOne,", () => {
counter.addOne();
expect(counter.number).toBe(1);
});
test("addTwo,", () => {
counter.addTwo();
expect(counter.number).toBe(2);
});
});
describe("分组2", () => {
beforeAll(()=>{})
beforeEach(() => {});
afterRAch(()=>{})
afterAll(()=>{})
test("minusOne,", () => {
counter.minusOne();
expect(counter.number).toBe(-1);
});
test("minusTwo,", () => {
counter.minusTwo();
expect(counter.number).toBe(-2);
});
});
});
jest 中的 mock
在很多时候测试用例需要在相关环境下才能正常运行,jest 提供了丰富的环境模拟支持
mock函数
使用 jest.fn() mock一个函数,mock函数有.mock 属性
test("returnCallback", () => {
//创建mock函数
const func = jest.fn();
//模拟函数返回值
func.mockReturnValue("callbackDone");
returnCallback(func);
//mock属性,函数被调用及返回值信息
expect(func.mock.results[0].value).toBe("callbackDone");
});
mock Ajax请求
某些功能依赖了axios发异步请求,在实际测试的时候我们希望直接返回既定结果,不用发请求,就可以 mock axios
//ajax
export const getData = () => {
return axios.get("/api").then((res) => res.data);
};
//test
import Axios from "axios";
//mock axios,不会真正发送ajax请求
jest.mock("axios");
test("getData", async () => {
//模拟ajax请求返回结果
Axios.get.mockResolvedValue({ data: "hello" });
// 也可以模拟其实现
// axios.get.mockImplementation(() => Promise.resolve(resp));
await getData().then((data) => {
expect(data).toBe("hello");
});
});
Timer Mocks
对使用延时器模块进行测试,由于延时时间不一定,每次测试都要等待,影响测试效率,jest 能够模拟延时器,加快测试用例执行
export const timer = (callback) => {
setTimeout(() => {
callback();
}, 3000);
};
jest.useFakeTimers();
test("timer", () => {
const fn = jest.fn();
timer(fn);
//快进3000ms
jest.advanceTimersByTime(3000);
expect(fn).toHaveBeenCalledTimes(1);
});
mock模块
假如有一个模块引用另一个模块的方法
import Util from "./util";
export const demoFun = () => {
const util = new Util();
util.a();
util.b();
};
使用 jest.mock(模块名) 可以mock一个模块
jest.mock("./util");
import Util from "./util";
import { demoFun } from "./demoFun";
test("测试demoFun", () => {
demoFun();
expect(Util).toHaveBeenCalled();
expect(Util.mock.instances[0].a).toHaveBeenCalled();
expect(Util.mock.instances[0].b).toHaveBeenCalled();
});
jest.mock发现Util是一个类,会自动把类的构造函数和方法变成jest.fn()
const Util = jest.fn();
Util.init = jest.fn();
Util.a = jest.fn();
Util.b = jest.fn();
snapShoot 快照
项目中经常有一些配置文件。比如:
//config.js
export const generateConfig = () => {
return {
server: "http://localhost",
port: 8080,
};
};
那测试它的测试用例可以这样写
import { generateConfig } from './snapshot.js'
test('测试 generateConfig', () => {
expect(generateConfig()).toEqual({
server: 'http://localhost',
port: '8080'
})
当配置文件不断增加时,要不断同步测试用例,jest提供snapshoot,我们可以这样写
import { generateConfig } from './snapshot.js'
test("toMatchSnapshot", () => {
expect(generateConfig()).toMatchSnapshot();
});
toMatchSnapshot() 会为expect 的结果做一个快照并与前面的快照做匹配,如果前面没有快照那就会保存当前生成的快照,如果确实要改配置文件,那就要更新快照
假设配置中存在随机的变量,如下
export const generateConfig = () => {
return {
server: 'http://localhost',
port: '8080',
time: new Date()
}
因为每次生成的快照都与之前的不同,所以测试用例不会通过,在case中可以使用expect.any()
import { generateConfig } from './snapshot.js'
test('测试 generateConfig', () => {
expect(generateConfig()).toMatchSnapshot({
time: expect.any(Date)
})
之前使用snapshoot都会生成一个单独的快照文件,另一种方法行内快照,需要先安装pretter
npm install pretter --save
修改测试用例
test("toMatchInlineSnapshot", () => {
expect(generateConfig()).toMatchInlineSnapshot({
date: expect.any(Date),
});
});
运行测试用例,快照被保存到测试用例代码中
import { generateConfig } from "./snapshot.js";
test("测试 generateConfig", () => {
expect(generateConfig()).toMatchInlineSnapshot(
{
time: expect.any(Date)
},
`
Object {
"port": "8080",
"server": "http://localhost",
"time": Any<Date>,
}
`
);
});
测试覆盖率报告
jest 还提供了测试覆盖率的支持,执行命令 npm test -- --coverage 或者配置 package.json
"scripts": {
"test": "jest",
"coverage": "jest --coverage"
}
执行测试命令即可
命令执行完成会在项目根目录添加
coverage文件夹,使用浏览器打开coverage/lcov-report/index.html文件,有可视化的测试报告
Vue中集成jest测试
搭载测试环境
对于新项目来说,可以使用VUE-CLI工具创建项目
只需要在选择配置时选Unit Testing单元测试,并选择JEST作为测试框架即可
而对于现有项目而言(针对@vue/cli)想增加jest测试模块。运行以下命令行就会帮我们去安装jest模块。
vue add unit-jest
jest配置
集成 Jest 后,会在根目录下生成一个jest.config.js文件。并配置了@vue/cli-plugin-unit-jest,这个预设。预设配置为如下内容。
odule.exports = {
moduleFileExtensions: [
"js",
"jsx",
"json",
// tell Jest to handle *.vue files
"vue"
],
transform: {
// process *.vue files with vue-jest
"^.+\\.vue$": require.resolve("vue-jest"),
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": require.resolve(
"jest-transform-stub"
),
"^.+\\.jsx?$": require.resolve("babel-jest")
},
transformIgnorePatterns: ["/node_modules/"],
// support the same @ -> src alias mapping in source code
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
},
testEnvironment: "jest-environment-jsdom-fifteen",
// serializer for snapshots
snapshotSerializers: ["jest-serializer-vue"],
testMatch: ["**/tests/unit/**/*.spec.[jt]s?(x)", "**/__tests__/*.[jt]s?(x)"],
// https://github.com/facebook/jest/issues/6766
testURL: "http://localhost/",
watchPlugins: [
require.resolve("jest-watch-typeahead/filename"),
require.resolve("jest-watch-typeahead/testname")
]
};
这个预设可能不能满足需求,我们可以对他进行一些拓展
挂载组件
要测试Vue组件行为,首先要开启渲染过程,在vue里面也就是需要先挂载组件。
挂载组件,需要将组件转换为一个构造函数。而vue组件选项只是一个普通的JavaScript对象。这时可以使用Vue.extend方法从选项中创建一个vue构造函数:
import Vue from 'vue';
import TodoList from "../todoList";
const Cons = Vue.extend(TodoList);
现在就可以使用new操作符创建实例:
const vm = new Cons();
Vue创建实例时,不会自动挂载生成DOM节点,需要手动调用$mount方法:
const vm = new Cons().$mount()
当调用完el属性访问这些节点:
expect(vm.$el.textContent).toContain('todoList')
Jest会在jsdom库创建的虚拟浏览器环境中运行测试用例,所以vue组件的单元测试虽然要创建DOM树,并不意味必须在浏览器环境中运行。
import TodoList from "../todoList";
import Vue from "vue";
describe("todolist.vue", () => {
const msg = "todoList";
const Cstor = Vue.extend(TodoList);
const vm = new Cstor().$mount();
it("render", () => {
expect(vm.$el.textContent).toContain(msg);
});
});
运行测试用例
npm run test:unit
Vue Test Utils
挂载组件需要自己去创建构造函数并且手动挂载,vue官方的单元测试实用工具库Vue Test Utils,会让Vue组件单元测试变得更加简单。它包含一些辅助方法可以实现组件挂载、与组件交互以及断言组件输出。
mount挂载
Vue Test Utils会导出mount方法,该方法在接收一个组件后,会将其挂载并返回一个包含被挂载组件实例vm的包装器对象。包装器不仅仅只有实例vm,还包括一些辅助方法。其中一个方法就是text,它返回实例的textContent。
import { mount } from "@vue/test-utils";
it("renders msg when mounted", () => {
const nsg="todoList"
//使用mount方法挂载组件
const wrapper = mount(TodoList);
//检查文本内容,text方法返回组件渲染的所有文本
expect(wrapper.text()).toContain(msg)
});
shallowMount挂载
shallowMount也是挂载组件的方法,与mount不同的是,他只渲染一层组件树,挂载之前对所有子组件存根,shallowMount能确保对一个组件进行独立测试避免因子组件的渲染输出影响结果
测试方案选型
TDD+单元测试
- TDD一般结合单元测试使用,是白盒测试
- 先写测试用例再写代码
- 测试重点在代码
- 测试覆盖率高
- 代码量大,尤其对vue组件测试时,与业务代码耦合度高
- 过于独立,不能保证模块组合起来仍然好用,安全性低
BDD+集成测试
- 先写代码再写测试用例
- BDD一般结合集成测试使用,事黑盒测试
- 测试覆盖率相对低
- 测试重点在UI(DOM)
小结:
一个项目中不是必须只有一种测试方式,可以根据实际情况组合使用,对工具函数,工具类,重点在代码逻辑的测试可采用TDD+单元测试的方式,对Vue单文件组件一类的,重点在UI(DOM)的测试可采用BDD+集成测试的方式,合理采用高效的测试方法可以加快开发速度,提高代码质量,尽早发现并去除代码中的 BUG。