TDD和BDD有各自的使用场景,BDD一般偏向于系统功能和业务逻辑的自动化测试设计;而TDD在快速开发并测试功能模块的过程中则更加高效,以快速完成开发为目的。
Jest是Facebook开源的一个前端测试框架,主要用于React和React Native的单元测试,已被集成在create-react-app中。Jest特点:
Enzyme是Airbnb开源的React测试工具库库,它功能过对官方的测试工具库ReactTestUtils的二次封装,提供了一套简洁强大的 API,并内置Cheerio,
实现了jQuery风格的方式进行DOM 处理,开发体验十分友好。在开源社区有超高人气,同时也获得了React 官方的推荐。
安装Jest、Enzyme,以及babel-jest。如果React的版本是15或者16,需要安装对应的enzyme-adapter-react-15和enzyme-adapter-react-16并配置。
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
复制代码
在package.json中的script中增加"test: jest --config .jest.js"
.jest.js文件
module.exports = {
setupFiles: [
'./test/setup.js',
],
moduleFileExtensions: [
'js',
'jsx',
],
testPathIgnorePatterns: [
'/node_modules/',
],
testRegex: '.*\\.test\\.js$',
collectCoverage: false,
collectCoverageFrom: [
'src/components/**/*.{js}',
],
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js",
"\\.(css|less|scss)$": "/__mocks__/styleMock.js"
},
transform: {
"^.+\\.js$": "babel-jest"
},
};
复制代码
全局和describe都可以有上面四个周期函数,describe的after函数优先级要高于全局的after函数,describe的before函数优先级要低于全局的before函数
beforeAll(() => {
console.log('global before all');
});
afterAll(() => {
console.log('global after all');
});
beforeEach(() =>{
console.log('global before each');
});
afterEach(() => {
console.log('global after each');
});
describe('test1', () => {
beforeAll(() => {
console.log('test1 before all');
});
afterAll(() => {
console.log('test1 after all');
});
beforeEach(() => {
console.log('test1 before each');
});
afterEach(() => {
console.log('test1 after each');
});
it('test sum', () => {
expect(sum(2, 3)).toEqual(5);
});
it('test mutil', () => {
expect(sum(2, 3)).toEqual(7);
});
});
复制代码
Jest拥有丰富的配置项,可以写在package.json里增加增加jest字段来进行配置,或者通过命令行--config来指定配置文件。
使用mock函数可以轻松的模拟代码之间的依赖,可以通过fn或spyOn来mock某个具体的函数;通过mock来模拟某个模块。具体的API可以看mock-function-api。
快照会生成一个组件的UI结构,并用字符串的形式存放在__snapshots__文件里,通过比较两个字符串来判断UI是否改变,因为是字符串比较,所以性能很高。
要使用快照功能,需要引入react-test-renderer库,使用其中的renderer方法,jest在执行的时候如果发现toMatchSnapshot方法,会在同级目录下生成一个__snapshots文件夹用来存放快照文件,以后每次测试的时候都会和第一次生成的快照进行比较。可以使用jest --updateSnapshot来更新快照文件。
Jest支持对异步的测试,支持Promise和Async/Await两种方式的异步测试。
三种方法中,shallow和mount因为返回的是DOM对象,可以用simulate进行交互模拟,而render方法不可以。一般shallow方法就可以满足需求,如果需要对子组件进行判断,需要使用render,如果需要测试组件的生命周期,需要使用mount方法。
todo-list/index.js
import React, { Component } from 'react';
import { Button } from 'antd';
export default class TodoList extends Component {
constructor(props) {
super(props);
this.handleTest2 = this.handleTest2.bind(this);
}
handleTest = () => {
console.log('test');
}
handleTest2() {
console.log('test2');
}
componentDidMount() {}
render() {
return (
{this.props.list.map((todo, index) => (
{todo}
))}
);
}
}
复制代码
const props = {
list: ['first', 'second'],
deleteTodo: jest.fn(),
};
const setup = () => {
const wrapper = shallow( );
return {
props,
wrapper,
};
};
const setupByRender = () => {
const wrapper = render( );
return {
props,
wrapper,
};
};
const setupByMount = () => {
const wrapper = mount( );
return {
props,
wrapper,
};
};
复制代码
it('renders correctly', () => {
const tree = renderer
.create( )
.toJSON();
expect(tree).toMatchSnapshot();
});
复制代码
当使用toMatchSnapshot的时候,会生成一份组件DOM的快照,以后每次运行测试用例的时候,都会生成一份组件快照和第一次生成的快照进行对比,如果对组件的结构进行修改,那么生成的快照就会对比失败。可以通过更新快照重新进行UI测试。
it('should has Button', () => {
const { wrapper } = setup();
expect(wrapper.find('Button').length).toBe(2);
});
it('should render 2 item', () => {
const { wrapper } = setupByRender();
expect(wrapper.find('button').length).toBe(2);
});
it('should render item equal', () => {
const { wrapper } = setupByMount();
wrapper.find('.item-text').forEach((node, index) => {
expect(node.text()).toBe(wrapper.props().list[index])
});
});
it('click item to be done', () => {
const { wrapper } = setupByMount();
wrapper.find('Button').at(0).simulate('click');
expect(props.deleteTodo).toBeCalled();
});
复制代码
判断组件是否有Button这个组件,因为不需要渲染子节点,所以使用shallow方法进行组件的渲染,因为props的list有两项,所以预期应该有两个Button组件。
判断组件是否有button这个元素,因为button是Button组件里的元素,所有使用render方法进行渲染,预期也会找到连个button元素。
判断组件的内容,使用mount方法进行渲染,然后使用forEach判断.item-text的内容是否和传入的值相等使用simulate来触发click事件,因为deleteTodo被mock了,所以可以用deleteTodo方法时候被调用来判断click事件是否被触发。
//使用spy替身的时候,在测试用例结束后,要对spy进行restore,不然这个spy会一直存在,并且无法对相同的方法再次进行spy。
it('calls componentDidMount', () => {
const componentDidMountSpy = jest.spyOn(TodoList.prototype, 'componentDidMount');
const { wrapper } = setup();
expect(componentDidMountSpy).toHaveBeenCalled();
componentDidMountSpy.mockRestore();
});
复制代码
使用spyOn来mock 组件的componentDidMount,替身函数要在组件渲染之前,所有替身函数要定义在setup执行之前,并且在判断以后要对替身函数restore,不然这个替身函数会一直存在,且被mock的那个函数无法被再次mock。
it('calls component handleTest', () => { // class中使用箭头函数来定义方法
const { wrapper } = setup();
const spyFunction = jest.spyOn(wrapper.instance(), 'handleTest');
wrapper.instance().handleTest();
expect(spyFunction).toHaveBeenCalled();
spyFunction.mockRestore();
});
it('calls component handleTest2', () => { //在constructor使用bind来定义方法
const spyFunction = jest.spyOn(TodoList.prototype, 'handleTest2');
const { wrapper } = setup();
wrapper.instance().handleTest2();
expect(spyFunction).toHaveBeenCalled();
spyFunction.mockRestore();
});
复制代码
使用instance函数来取得组件的实例,并用spyOn方法来mock实例上的内部方法,然后用这个实例去调用那个内部方法,就可以用替身来判断这个内部函数是否被调用。如果内部方法是用箭头函数来定义的时候,需要对实例进行mock;如果内部方法是通过正常的方式或者bind的方式定义的,那么需要对组件的prototype进行mock。其实对生命周期或者内部函数的测试,可以通过一些state的改变进行判断,因为这些函数的调用一般都会对组件的state进行一些操作。
add/index.js
import { add } from 'lodash';
import { multip } from '../../utils/index';
export default function sum(a, b) {
return add(a, b);
}
export function m(a, b) {
return multip(a, b);
}
复制代码
add/__test__/index.test.js
import sum, { m } from '../index';
jest.mock('lodash');
jest.mock('../../../utils/index');
describe('test mocks', () => {
it('test sum', () => {
expect(sum(2, 3)).toEqual(5);
});
it('test mutilp', () => {
expect(m(2, 3)).toEqual(7);
});
});
复制代码
_mocks_:
在测试文件中使用mock()方法对要进行mock的文件进行引用,Jest就会自动去寻找对应的__mocks__中的文件并进行替换,lodash中的add和utils中的multip方法就会被mock成对应的方法。可以使用自动代理的方式对项目的异步组件库(fetch、axios)进行mock,或者使用fetch-mock、jest-fetch-mock来模拟异步请求。
async/index.js
import request from './request';
export function getUserName(userID) {
return request(`/users/${userID}`).then(user => user.name);
}
async/request.js
const http = require('http');
export default function request(url) {
return new Promise((resolve) => {
// This is an example of an http request, for example to fetch
// user data from an API.
// This module is being mocked in __mocks__/request.js
http.get({ path: url }, (response) => {
let data = '';
response.on('data', _data => (data += _data));
response.on('end', () => resolve(data));
});
});
}
复制代码
mock request:
const users = {
4: {
name: 'hehe',
},
5: {
name: 'haha',
},
};
export default function request(url) {
return new Promise((resolve, reject) => {
const userID = parseInt(url.substr('/users/'.length), 10);
process.nextTick(() => {
users[userID] ?
resolve(users[userID]) :
reject({
error: `User with ${userID} not found.`,
});
});
});
}
复制代码
request.js可以看成是一个用于请求数据的模块,手动mock这个模块,使它返回一个Promise对象,用于对异步的处理。
// 使用'.resolves'来测试promise成功时返回的值
it('works with resolves', () => {
// expect.assertions(1);
expect(user.getUserName(5)).resolves.toEqual('haha')
});
// 使用'.rejects'来测试promise失败时返回的值
it('works with rejects', () => {
expect.assertions(1);
return expect(user.getUserName(3)).rejects.toEqual({
error: 'User with 3 not found.',
});
});
// 使用promise的返回值来进行测试
it('test resolve with promise', () => {
expect.assertions(1);
return user.getUserName(4).then((data) => {
expect(data).toEqual('hehe');
});
});
it('test error with promise', () => {
expect.assertions(1);
return user.getUserName(2).catch((e) => {
expect(e).toEqual({
error: 'User with 2 not found.',
});
});
});
复制代码
当对Promise进行测试时,一定要在断言之前加一个return,不然没有等到Promise的返回,测试函数就会结束。可以使用.promises/.rejects对返回的值进行获取,或者使用then/catch方法进行判断。
// 使用async/await来测试resolve
it('works resolve with async/await', async () => {
expect.assertions(1);
const data = await user.getUserName(4);
expect(data).toEqual('hehe');
});
// 使用async/await来测试reject
it('works reject with async/await', async () => {
expect.assertions(1);
try {
await user.getUserName(1);
} catch (e) {
expect(e).toEqual({
error: 'User with 1 not found.',
});
}
});
复制代码
使用async不用进行return返回,并且要使用try/catch来对异常进行捕获。
代码覆盖率是一个测试指标,用来描述测试用例的代码是否都被执行。统计代码覆盖率一般要借助代码覆盖工具,Jest集成了Istanbul这个代码覆盖工具。
在四个维度中,如果代码书写的很规范,行覆盖率和语句覆盖率应该是一样的。会触发分支覆盖率的情况有很多种,主要有以下几种:
function test(a, b) {
a = a || 0;
b = b || 0;
if (a && b) {
return a + b;
} else {
return 0;
}
}
test(1, 2);
// test();
复制代码
当执行test(1,2)的时候,代码覆盖率为
当执行test()的时候,代码覆盖率为
stanbul可以在命令行中设置各个覆盖率的门槛,然后再检查测试用例是否达标,各个维度是与的关系,只要有一个不达标,就会报错。
当statement和branch设置为90的时候,覆盖率检测会报
当statemen设置为80t、branch设置为50的时候,覆盖率检测会通过
在Jest中,可以通过coverageThreshold这个配置项来设置不同测试维度的覆盖率阈值。global是全局配置,默认所有的测试用例都要满足这个配置才能通过测试。还支持通配符模式或者路径配置,如果存在这些配置,那么匹配到的文件的覆盖率将从全局覆盖率的计算中去除,独立使用各自设置的阈值。
{
...
"jest": {
"coverageThreshold": {
"global": {
"branches": 50,
"functions": 50,
"lines": 50,
"statements": 50
},
"./src/components/": {
"branches": 40,
"statements": 40
},
"./src/reducers/**/*.js": {
"statements": 90,
},
"./src/api/very-important-module.js": {
"branches": 100,
"functions": 100,
"lines": 100,
"statements": 100
}
}
}
}
复制代码
在项目中引用单元测试后,希望每次修改需要测试的文件时,能在提交代码前自动跑一边测试用例,保证代码的正确性和健壮性。
在项目中可以使用husky和lint-staged,用来触发git的hooks,做一些代码提交前的校验。
在package.json中,precommit执行lint-staged,对lint-staged进行配置,对所有的js文件进行eslint检查,对src/components中的js文件进行测试。
{
"scripts": {
"precommit": "lint-staged",
},
"lint-staged": {
"ignore": [
"build/*",
"node_modules"
],
"linters": {
"src/*.js": [
"eslint --fix",
"git add"
],
"src/components/**/*.js": [
"jest --findRelatedTests --config .jest.js",
"git add"
]
}
},
}
复制代码
对containers中的文件进行修改,然后推进暂存区的时候,会进行eslint的检查,但是不会进行测试
对components中的todo-list进行修改,eslint会进行检查,并且会执行todo-list这个组件的测试用例,因为改变了组件的结构,所以快照进行UI对比就会失败
下面是配套资料,对于做【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴我走过了最艰难的路程,希望也能帮助到你!
最后: 可以在公众号:程序员小濠 ! 免费领取一份216页软件测试工程师面试宝典文档资料。以及相对应的视频学习教程免费分享!,其中包括了有基础知识、Linux必备、Shell、互联网程序原理、Mysql数据库、抓包工具专题、接口测试工具、测试进阶-Python编程、Web自动化测试、APP自动化测试、接口自动化测试、测试高级持续集成、测试架构开发测试框架、性能测试、安全测试等。
如果我的博客对你有帮助、如果你喜欢我的博客内容,请 “点赞” “评论” “收藏” 一键三连哦!喜欢软件测试的小伙伴们,可以加入我们的测试技术交流扣扣群:779450660里面有各种软件测试资源和技术讨论)