前端单元测试
前端单元测试概述
前端测试工具
测试分为e2e测试和单元测试和集成测试
e2e:端到端的测试,主要是测业务,绝大部分情况是指在浏览器上对某一个网站的某一个功能进行操作。
单元测试工具:mache、ava、jest、jasmine等
断言库: shoud.js.chai.js 等
测试覆盖率工具:istanbul
react 采用jest加enzyne的写法 e2e 测试pupertear
vue 采用jest e2e 适应nightwatch 的方案
测试分为三个种类
-
单元测试
在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法
-
集成测试
集成测试,也叫组装测试或联合测试。在单元测试的基础上,将所有模块按照设计要求(如根据结构图)组装成为子系统或系统,进行集成测试。
-
功能测试
功能测试就是对产品的各功能进行验证,根据功能测试用例,逐项测试,检查产品是否达到用户要求的功能。
单元测试和集成测试简单对比
React & Redux 应用构建在三个基本的构建块上:actions、reducers 和 components。是独立测试它们(单元测试),还是一起测试(集成测试)取决于你。集成测试会覆盖到整个功能,可以把它想成一个黑盒子,而单元测试专注于特定的构建块。从我的经验来看,集成测试非常适用于容易增长但相对简单的应用。另一方面,单元测试更适用于逻辑复杂的应用。尽管大多数应用都适合第一种情况,但我将从单元测试开始更好地解释应用层。
为何选用jest
- 方便的异步测试
- snapshot功能(快照测试)
- 集成断言库,不许要引用其他第三方库
- 对React天生支持
- 零配置
- 内置代码覆盖率
- 强大的Mocks
Jest 安装与配置
vue中直接选就可以
在其他的项目中,直接测试就可以
npm install --save-dev jest
在package.json中添加
// 添加测试命令
{
"scripts": {
"test": "jest"
}
}
执行命令
npm test
Jest 的测试脚本名形如.test.js,不论 Jest 是全局运行还是通过npm test运行,它都会执行当前目录下所有的.test.js 或 *.spec.js 文件、完成测试
Jest 的api与概念
匹配器(Matchers)
1、相等匹配
expact(2 + 2) 将返回我们期望的结果, toBe 就是一个matcher
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
toBe 是测试具体的某一个值,如果需要测试对象,需要用到toEqual,toEqual是通过递归检查对象或数组的每个字段。
test('object assignment', () => {
const data = {one: 1};
data['two'] = 2;
expect(data).toEqual({one: 1, two: 2});
});
2、真实性匹配,比如:对象是否为null,集合是否为空等等
在测试中,您有时需要区分undefined、null和false,但有时希望以不同的方式处理这些问题,Jest帮助你明确您想要什么。比如:
- toBeNull 仅当expect返回对象为 null时
- toBeUndefined 仅当返回为 undefined
- toBeDefined 和上面的刚好相反,对象如果有定义时
- toBeTruthy 匹配任何返回结果为true的
- toBeFalsy 匹配任何返回结果为false的
3、数字型匹配
test('two plus two', () => {
const value = 2 + 2;
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
// toBe and toEqual are equivalent for numbers
expect(value).toBe(4);
expect(value).toEqual(4);
});
于float类型的浮点数计算的时候,需要使用toBeCloseTo而不是 toEqual ,因为避免细微的四舍五入引起额外的问题
4、字符型匹配 toMatch 匹配规则,支持正则表达式匹配
test('there is no I in team', () => {
expect('team').not.toMatch(/I/);
});
test('but there is a "stop" in Christoph', () => {
expect('Christoph').toMatch(/stop/);
});
5、数组类型匹配 toContain 检查是否包含
const shoppingList = [
'diapers',
'kleenex',
'trash bags',
'paper towels',
'beer',
];
test('the shopping list has beer on it', () => {
expect(shoppingList).toContain('beer');
});
6、异常匹配 测试function是否会抛出特定的异常信息,可以用 toThrow 规则
function compileAndroidCode() {
throw new ConfigError('you are using the wrong JDK');
}
test('compiling android goes as expected', () => {
expect(compileAndroidCode).toThrow();
expect(compileAndroidCode).toThrow(ConfigError);
// You can also use the exact error message or a regexp
expect(compileAndroidCode).toThrow('you are using the wrong JDK');
expect(compileAndroidCode).toThrow(/JDK/);
});
Asynchronous(测试异步代码)
1、回调函数
done() 被执行则意味着callback函数被调用
function fetchData(callback) {
setTimeout(() => {
callback('2')
}, 2000)
}
test('data is 2', done => {
function callback(data) {
expect(data).toBe('2');
done();
}
fetchData(callback)
})
2、promise验证
assertions(1)代表的是在当前的测试中至少有一个断言是被调用的,否则判定为失败。
在Jest 20.0.0+ 的版本中你可以使用 .resolves 匹配器在你的expect语句中,Jest将会等待一直到承诺被实现,如果承诺没有被实现,测试将自动失败。
如果你期望你的承诺是不被实现的,你可以使用 .rejects ,它的原理和 .resolves类似
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('2')
}, 2000)
})
}
test('data is 2', () => {
expect.assertions(1);
return expect(fetchData()).resolves.toBe('2');
})
test('data is 2', () => {
expect.assertions(1);
return expect(fetchData()).rejects.toMatch('error');
});
3、使用 Async/Await
function fetchData(num) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if(num) {
reject('error')
} else {
resolve('2')
}
}, 2000)
})
}
test('data is 2', () => {
expect.assertions(1);
return expect(fetchData()).resolves.toBe('2');
})
test('the data is 2', async () => {
expect.assertions(1);
const data = await fetchData();
expect(data).toBe('2');
});
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData(1);
} catch (e) {
expect(e).toMatch('error');
}
});
当然你也可以将Async Await和 .resolves .rejects 结合起来(Jest 20.0.0+ 的版本)
test('the data is peanut butter', async () => {
expect.assertions(1);
await expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
expect.assertions(1);
await expect(fetchData()).rejects.toMatch('error');
});
Mock Functions(模拟器)
在写单元测试的时候有一个最重要的步骤就是Mock,我们通常会根据接口来Mock接口的实现,比如你要测试某个class中的某个方法,而这个方法又依赖了外部的一些接口的实现,从单元测试的角度来说我只关心我测试的方法的内部逻辑,我并不关注与当前class本身依赖的实现,所以我们通常会Mock掉依赖接口的返回,因为我们的测试重点在于特定的方法,所以在Jest中同样提供了Mock的功能
Jest中有两种方式的Mock Function,一种是利用Jest提供的Mock Function创建,另外一种是手动创建来覆写本身的依赖实现。
1、 jest.fn() 方式
every
function every(array, predicate) {
let index = -1
const length = array == null ? 0 : array.length
while (++index < length) {
if (!predicate(array[index], index, array)) {
return false
}
}
return true
}
module.exports = every
foreach
function foreach(arr, fn) {
for(let i = 0, len = arr.length; i < len; i++) {
fn(arr[i]);
}
}
module.exports = foreach;
const foreach = require('./foreach');
const every = require('./every');
describe('mock test', () => {
it('test foreach use mock', () => {
// 通过jest.fn() 生成一个mock函数
const fn = jest.fn();
foreach([1, 2, 3], fn);
// 测试mock函数被调用了3次
expect(fn.mock.calls.length).toBe(3);
// 测试第二次调用的函数第一个参数是3
expect(fn.mock.calls[2][0]).toBe(3);
})
it('test every use mock return value', () => {
const fn = jest.fn();
// 可以设置返回值
fn
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const res = every([1, 2, 3, 4], fn);
expect(fn.mock.calls.length).toBe(2);
expect(fn.mock.calls[1][1]).toBe(1);
})
it('test every use mock mockImplementationOnce', () =>{
// 快速定义mock的函数体,方便测试
const fn = jest.fn((val, index) => {
if(index == 2) {
return false;
}
return true;
});
const res = every([1, 2, 3, 4], fn);
expect(fn.mock.calls.length).toBe(3);
expect(fn.mock.calls[1][1]).toBe(1);
})
})
2、手动
假如我的测试文件sum2.js
function sum2(a, b) {
if (a > 10) return a * b;
return a + b;
}
export default sum2;
现在如果我们要mock sum2.js 文件的话,需要在sum2.js 同级目录下新建文件夹__mock__,
然后在此文件下新建文件同名 sum2.js, 只是单纯的返回100
export default function sum2(a, b) {
return 100;
}
测试用例mock_file.test.js
jest.mock('../src/sum2');
import sum2 from '../src/sum2';
it('test mock sum2', () => {
// 因为此时访问的是__mock__文件夹下的sum2.js 所以测试通过
expect(sum2(1, 11111)).toBe(100);
})
手动mock的好处是测试和模拟分离。可以很方便的修改测试用例。如果是复杂的mock建议使用手动新建文件方式
方便的钩子与全局函数
- beforeEach(fn)每一个函数之前
- afterEach(fn) 每一个函数之后
- beforeAll(fn) 所有的之前
- afterAll(fn) 所有的之后
class Hook {
constructor() {
this.init();
}
init() {
this.a = 1;
this.b = 1;
}
sum() {
return this.a + this.b;
}
}
describe('hook', () => {
const hook = new Hook;
// 每个测试用例执行前都会还原数据,所以下面两个测试可以通过。
beforeEach( () => {
hook.init();
})
test('test hook 1', () => {
hook.a = 2;
hook.b = 2;
expect(hook.sum()).toBe(4);
})
test('test hook 2', () => {
expect(hook.sum()).toBe(2);// 测试通过
})
})
describe(name, fn)
describe(name, fn)创建一个块,在一个“测试套件”中,将几个相关的测试组合在一起
const myBeverage = {
delicious: true,
sour: false,
};
describe('my beverage', () => {
test('is delicious', () => {
expect(myBeverage.delicious).toBeTruthy();
});
test('is not sour', () => {
expect(myBeverage.sour).toBeFalsy();
});
});
这不是必需的——你可以直接在顶层编写测试块。但是,如果您希望将测试组织成组,那么这就很方便了
describe.only(name, fn)
如果你只想运行一次模块测试的话你可以使用 only
describe.only('my beverage', () => {
test('is delicious', () => {
expect(myBeverage.delicious).toBeTruthy();
});
test('is not sour', () => {
expect(myBeverage.sour).toBeFalsy();
});
});
describe('my other beverage', () => {
// ... will be skipped
});
describe.skip(name, fn) describe 等价于 xdescribe
你可以使用skip 跳过某一个测试
describe('my beverage', () => {
test('is delicious', () => {
expect(myBeverage.delicious).toBeTruthy();
});
test('is not sour', () => {
expect(myBeverage.sour).toBeFalsy();
});
});
describe.skip('my other beverage', () => {
// ... will be skipped
});
使用跳过通常只是一种比较简单的替代方法,如果不想运行则可以暂时将大量的测试注释掉。
require.requireActual(moduleName)
返回实际的模块而不是模拟,绕过所有检查模块是否应该接收模拟实现。
require.requireMock(moduleName)
返回一个模拟模块,而不是实际的模块,绕过所有检查模块是否正常。
test(name, fn, timeout) 等价于 it(name, fn, timeout)
在测试文件中,您所需要的是运行测试的测试方法。例如,假设有一个函数inchesOfRain()应该是零。你的整个测试可以是:
test('did not rain', () => {
expect(inchesOfRain()).toBe(0);
});
第一个参数是测试名称;第二个参数是包含测试期望的函数。第三个参数(可选)是超时(以毫秒为单位),用于指定在中止前等待多长时间。注意:默认的超时是5秒。
注意:如果测试返回了一个promise,Jest会在测试完成之前等待promise。Jest还将等待,如果你为测试函数提供一个参数,通常称为done。当你想要测试回调时,这将非常方便。请参见如何在此测试异步代码。
test.only(name, fn, timeout)等同于 it.only(name, fn, timeout) or fit(name, fn, timeout)
test.skip(name, fn)等同于it.skip(name, fn) or xit(name, fn) or xtest(name, fn)
当您维护一个大型的代码库时,您可能有时会发现由于某种原因而临时中断的测试。
如果您想跳过这个测试,但是您不想仅仅删除这个代码,您可以使用skip指定一些测试来跳过。
test('it is raining', () => {
expect(inchesOfRain()).toBeGreaterThan(0);
});
test.skip('it is not snowing', () => {
expect(inchesOfSnow()).toBe(0);
});
只有“it is raining”测试运行,因为另一个测试运行test . skip。 您可以简单地对测试进行注释,但是使用skip会更好一些,因为它将保持缩进和语法突出。
测试覆盖率
Jest 内置了测试覆盖率工具istanbul,要开启,可以直接在命令中添加 --coverage 参数,或者在 package.json 文件进行更详细的配置。
搭配React和其它框架的使用 快照功能
快照测试第一次运行的时候会将被测试ui组件在不同情况下的渲染结果保存一份快照文件。后面每次再运行快照测试时,都会和第一次的比较。
import React from 'react';
export default class RC extends React.Component {
render() {
return (
<div>我是react组件 </div>
)
}
}
import React from 'react';
import renderer from 'react-test-renderer';
import RC from '../src/react-comp';
test('react-comp snapshot test', () => {
const component = renderer.create(<RC />);
//
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
})
test('react-comp snapshot test2', () => {
const component = renderer.create(<RC />);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
})
执行测试命令,会在test目录下生成一个__snapshots__目录,在此目录下会与一个文件叫snapshot.test.js.snap的快照文件
迁移
使用
npm install -g jest-codemods
然后jest-codemods
enzyme API
shallow() 渲染函数只渲染我们专门测试的组件, 它不会渲染子元素。相反, 用mount()
1、使用 shallow
mport { shallow } from 'enzyme';
const wrapper = shallow(<MyComponent />);
我们刚刚可以看到这个测试里用到shallow函数,它支持对DOM的进行结构和事件的响应,如果你对jQuery比较熟悉的话,那么你对它的语法也不会陌生。比如我们测试里用到的find方法,大家经常用它来寻找一些DOM数组。
简单罗列下它所支持的方法:
- find(selector) 查找选择器下的DOM 元素,返回一个数组。
- contains(node) 确定是否包含该节点或者一些节点 ,返回true 或者 false
- is(selector) 判断改节点是否能够匹配选择器的节点 ,返回true 或者 false
- hasClass(className) 判断是否包含这个类,返回true 或者 false
- prop[key] 返回组件上某个属性的值
- setState(props) 设置组件状态
- simulate(event[,mock]) 模拟一个节点上的事件
2、完全DOM渲染
import { mount } from 'enzyme';
const wrapper = mount(<MyComponent />);
完全DOM渲染主要用于与DOM API进行交互以及需要完整生命周期的组件测试(i.e componentDidMoun)。完全DOM渲染需要DOM 的 API 在全局作用域内。而且需要其运行在近似浏览器的环境里。如果你不想在浏览器里跑这些测试的话,强烈建议你使用mount,一个依赖于jsdom的类库,几乎等同于没有浏览器外壳的浏览器。它也支持了很多方法
3、静态渲染
静态渲染,enzyme还提供了静态渲染,将组件渲染成html,用于我们分析html的结构。render相比前两种用法, 主要是在于更换了类库 Cheerio ,而且作者也相信在处理解析上会更好点。
import { render } from 'enzyme';
const wrapper = render(<MyComponent />);
使用说明
如果我们在开发过程中就进行了测试(直接采用 TDD 开发模式、或者针对既有的模块写用例),
会有如下的好处:
- 保障代码质量和功能的实现的完整度
- 提升开发效率,在开发过程中进行测试能让我们提前发现 bug ,此时进行问题定位和修复的速度自然比开发完再被叫去修 bug 要快许多
- 便于项目维护,后续任何代码更新也必须跑通测试用例,即使进行重构或开发人员发生变化也能保障预期功能的实现
当然,凡事都有两面性,好处虽然明显,却并不是所有的项目都值得引入测试框架,毕竟维护测试用例也是需要成本的。对于一些需求频繁变更、复用性较低的内容,比如活动页面,让开发专门抽出人力来写测试用例确实得不偿失。而那些适合引入测试场景大概有这么几个:
- 需要长期维护的项目。它们需要测试来保障代码可维护性、功能的稳定性较为稳定的项目、或项目中
- 较为稳定的部分。给它们写测试用例,维护成本低
- 被多次复用的部分,比如一些通用组件和库函数。
因为多处复用,更要保障质量