大多數(shù)開發(fā)者都知道需要寫單元測試,但是不知道每個(gè)單元測試應(yīng)用的主要內(nèi)容以及如何做單元測試,在介紹jest測試框架前,我們先來了解下一些測試相關(guān)的概念。
為什么需要單元測試?
- 保證質(zhì)量:隨著迭代的過程,開發(fā)人員很難記清所有的功能點(diǎn),功能點(diǎn)的新增和刪除在代碼改變后,進(jìn)行回歸測試時(shí),依靠人工QA很容易出錯(cuò)遺漏。
- 自動(dòng)化:通過編寫測試用例,只需要編寫一次,多次運(yùn)行,同樣的事情不需要從頭再來測一遍,很多時(shí)候QA的工作量就是這么增加的,新的版本上線,人工QA都需要所有的功能點(diǎn)從新測試一遍。
- 特性文檔:單元測試可以作為描述和記錄代碼所實(shí)現(xiàn)的所有需求,有時(shí)候可以作為文檔來使用,了解一個(gè)項(xiàng)目可以通過閱讀測試用例比看需求文檔更清晰。
- 驅(qū)動(dòng)開發(fā),指導(dǎo)設(shè)計(jì):代碼被測試的前提是代碼本身的可測試性,那么要保證代碼的可測試性,就需要在開發(fā)中注意API的設(shè)計(jì),TDD將測試前移就是起到這么一個(gè)作用
測試類型
你可能接觸過各種測試框架、大體上,最重要測試類型有:
- 單元測試- 依靠模擬輸入證實(shí)是否是期望的輸出來分別的測試函數(shù)或者類。
- 集成測試 - 測試若干模塊來確保他們像預(yù)期的那樣工作。
- 功能測試- 在產(chǎn)品本身(例如在瀏覽器上)對一個(gè)場景進(jìn)行操作,而不考慮內(nèi)部結(jié)構(gòu)以確保預(yù)期的行為。
測試工具類型
測試工具可以分為以下功能,有些提供一個(gè)功能,有些提供了一個(gè)組合。
使用工具組合是很常見的,即使你可以使用單一的工具實(shí)現(xiàn)同樣的功能,是所有組合可以獲得更靈活的功能。
- 測試環(huán)境(Mocha , Jasmine, Jest, Karma)
- 測試結(jié)構(gòu) (Mocha , Jasmine, Jest, Cucumber)
- 斷言函數(shù)(Chai, Jasmine, Jest, Unexpected)
- 生成,顯示、監(jiān)聽測試結(jié)果(Mocha , Jasmine, JestKarma)
- 生成,比較組件和數(shù)據(jù)結(jié)構(gòu)的快照,以確保之前運(yùn)行的更改是預(yù)期的。(Jest,Ava)
- mocks。(sinon.js) 目前使用最多的mock庫,將其分為spies、stub、fake XMLHttpRequest、Fake server、Fake time幾種,根據(jù)不同的場景進(jìn)行選擇。
- 生成代碼覆蓋率報(bào)告。(Istanbul, Jest)
- 瀏覽器或者類瀏覽器環(huán)境執(zhí)行控制。(Protractor , Nightwatch, Phantom, Casper)
單元測試技術(shù)的實(shí)現(xiàn)原理
- 測試框架:判斷內(nèi)部是否存在異常,存在則console出對應(yīng)的text信息
- 斷言庫:當(dāng)actual值與expect值不一樣時(shí),就拋出異常,供外部測試框架檢測到,這就是為什么有些測試框架可以自由選擇斷言庫的原因,只要可以拋出異常,外部測試框架就可以工作。
- mock函數(shù):創(chuàng)建一個(gè)新的函數(shù),用這個(gè)函數(shù)來取代原來的函數(shù),同時(shí)在這個(gè)新函數(shù)上添加一些額外的屬性,例如called、calledWithArguments等信息
function describe (text, fn) {
try {
fn.apply(...);
} catch(e) {
assert(text)
}
}
function fn () {
while (...) {
beforeEach();
it(text, function () {
assert();
});
afterEach();
}
}
function it(text, fn) {
...
fn(text)
...
}
function assert (expect, actual) {
if (expect not equla actual ) {
throw new Error(text);
}
}
function fn () {
...
}
function spy(cb) {
var proxy = function () {
...
}
proxy.called = false;
proxy.returnValue = '...';
...
return proxy;
}
var proxy = spy(fn); // 得到一個(gè)mock函數(shù)
測試用例的鉤子
describe塊之中,提供測試用例的四個(gè)鉤子:before()、after()、beforeEach()和afterEach()。它們會(huì)在指定時(shí)間執(zhí)行。
describe('hooks', function() {
before(function() {
// 在本區(qū)塊的所有測試用例之前執(zhí)行
});
after(function() {
// 在本區(qū)塊的所有測試用例之后執(zhí)行
});
beforeEach(function() {
// 在本區(qū)塊的每個(gè)測試用例之前執(zhí)行
this.closeFunc = sinon.stub();
this.Modal = TestUtils.renderIntoDocument(
<Modal title="whatever" handleClose={this.closeFunc}>
<div className="m-content">
<p className="m-text">Just some noddy content</p>
<a href="#" className="other-link">Click me</a>
</div>
</Modal>
);
this.eventStub = {
preventDefault: sinon.stub(),
stopPropagation: sinon.stub(),
};
});
afterEach(function() {
// 在本區(qū)塊的每個(gè)測試用例之后執(zhí)行
});
// test cases
it('should have a title', function() {
var title = helpers.findByTag(this.Modal, 'h2');
assert.equal(findDOMNode(title).firstChild.nodeValue, 'whatever');
});
it('should have child content', function() {
var content = helpers.findByClass(this.Modal, 'm-content');
assert.equal(findDOMNode(content).nodeName.toLowerCase(), 'div');
});
it('should have child paragraph', function() {
var text = helpers.findByClass(this.Modal, 'm-text');
assert.equal(findDOMNode(text).firstChild.nodeValue,
'Just some noddy content');
});
});
如何寫單元測試用例
一些好的建議:
- 只考慮測試,不考慮內(nèi)部實(shí)現(xiàn)
- 不要做無謂的斷言
- 讓每個(gè)單元測試保持獨(dú)立
- 所有的方法都應(yīng)該寫單元測試
- 充分考慮數(shù)據(jù)的邊界條件
- 對重點(diǎn)、復(fù)雜、核心代碼,重點(diǎn)測試
- 利用AOP(beforeEach、afterEach),減少測試代碼數(shù)量,避免無用功能
- 使用最合適的斷言方式
TDD
一句話簡單來說,就是先寫測試,后寫功能實(shí)現(xiàn)。TDD的目的是通過測試用例來指引實(shí)際的功能開發(fā),讓開發(fā)人員首先站在全局的視角來看待需求。具體定義可以查看維基;
BDD
行為驅(qū)動(dòng)開發(fā)要求更多人員參與到軟件的開發(fā)中來,鼓勵(lì)開發(fā)者、QA、相關(guān)業(yè)務(wù)人員相互協(xié)作。BDD是由商業(yè)價(jià)值來驅(qū)動(dòng),通過用戶接口(例如GUI)理解應(yīng)用程序。詳見維基.
<blockquote>
Jest介紹--Painless JavaScript Testing
Jest 是一款 Facebook 開源的 JS 單元測試框架,目前 Jest 已經(jīng)在 Facebook 開源的 React, React Native 等前端項(xiàng)目中被做為標(biāo)配測試框架。
Jest功能:
- 內(nèi)置Jasmin語法
- 內(nèi)置auto mock
- 自帶mock API
- 前端友好(集成JSDOM)
- 支持直接使用Promise和async/await書寫異步代碼
- 支持對 React 組件進(jìn)行快照監(jiān)控
- 擴(kuò)展和集成 Babel 等常用工具集也很方便
- 自動(dòng)環(huán)境隔離
Jest用法
安裝:
npm install --save-dev jest
package.json中添加:
{
"scripts": {
"test": "jest"
}
}
運(yùn)行 npm test
也可通過命令行運(yùn)行:
jest my-test --notify --config=config.json
附加配置
npm install --save-dev babel-jest regenerator-runtime
項(xiàng)目根目錄添加.babelrc文件
{
"presets": ["es2015", "react"]
}
Jest自動(dòng)定義 NODE_ENV = test
測試腳本的寫法
下面是一個(gè)加法模塊add.js
的代碼。
// add.js
function add(x, y) {
return x + y;
}
module.exports = add;
要測試這個(gè)加法模塊是否正確,就要寫測試腳本。
通常,測試腳本與所要測試的源碼腳本同名,但是后綴名為.test.js
(表示測試)或者.spec.js
(表示規(guī)格)。比如,add.js
的測試腳本名字就是add.test.js
。
import add from '../src/add'
describe('加法函數(shù)測試', () => {
it('1加2應(yīng)該等于3', () => {
expect(add(1, 2)).toBe(3);
});
});
測試腳本里面應(yīng)該包括一個(gè)或多個(gè)describe塊,每個(gè)describe塊應(yīng)該包括一個(gè)或多個(gè)it塊。
describe塊稱為"測試套件"(test suite),表示一組相關(guān)的測試。它是一個(gè)函數(shù),第一個(gè)參數(shù)是測試套件的名稱("加法函數(shù)的測試"),第二個(gè)參數(shù)是一個(gè)實(shí)際執(zhí)行的函數(shù)。
it塊稱為"測試用例"(test case),表示一個(gè)單獨(dú)的測試,是測試的最小單位。它也是一個(gè)函數(shù),第一個(gè)參數(shù)是測試用例的名稱("1 加 1 應(yīng)該等于 2"),第二個(gè)參數(shù)是一個(gè)實(shí)際執(zhí)行的函數(shù)。
斷言庫的用法
expect(add(1, 2)).toBe(3);
所謂"斷言",就是判斷源碼的實(shí)際執(zhí)行結(jié)果與預(yù)期結(jié)果是否一致,如果不一致就拋出一個(gè)錯(cuò)誤。上面這句斷言的意思是,調(diào)用add(1, 1),結(jié)果應(yīng)該等于2。
所有的測試用例(it塊)都應(yīng)該含有一句或多句的斷言。它是編寫測試用例的關(guān)鍵。斷言功能由斷言庫來實(shí)現(xiàn)
簡單測試
// add.js
function add(a, b) {
return a + b;
}
module.exports = add;
// add.test.js
import add from '../src/add'
describe('加法函數(shù)測試', () => {
it('1加2應(yīng)該等于3', () => {
expect(add(1, 2)).toBe(3);
});
});
異步的單元測試
// user.js
import request from './request';
export function getUserName(userID) {
return request('/users/' + userID).then(user => user.name);
}
// user.test.js
import * as user from 'user';
// 普通回調(diào)
it('the data is peanut butter', done => {
function callback(data) {
expect(data).toBe('peanut butter');
done();
}
fetchData(callback);
});
// 方法需要返回一個(gè)promise對象
it('works with promises',() => {
return user.getUserName(5)
.then(name => expect(name).toEqual('Paul'));
});
// async/await
it('works with async/await', async () => {
const userName = await user.getUserName(4);
expect(userName).toEqual('Mark');
})
React組件的單元測試
// CheckboxWidthLabel.js
import React from 'react';
export default class CheckboxWithLabel extends React.Component {
constructor(props) {
super(props);
this.state = { isChecked: false };
this.onChange = this.onChange.bind(this);
}
onChange() {
this.setState({ isChecked: !this.state.isChecked });
}
render() {
return (
<label >
<input
type = "checkbox"
checked = { this.state.isChecked }
onChange = { this.onChange }
/>
{ this.state.isChecked ? this.props.labelOn : this.props.labelOff }
</label >
)
}
}
//CheckboxWithLabel-test.js
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-dom/test-utils';
import CheckboxWidthLabel from 'CheckboxWithLabel';
it('CheckboxWithlabel changes the text after click', () => {
const checkbox = TestUtils.renderIntoDocument( <
CheckboxWidthLabel labelOn = "On"
labelOff = "Off" / >
);
const checkboxNode = ReactDOM.findDOMNode(checkbox);
expect(checkboxNode.textContent).toEqual('Off');
TestUtils.Simulate.change(
TestUtils.findRenderedDOMComponentWithTag(checkbox, 'input')
)
expect(checkboxNode.textContent).toEqual('On');
})
手動(dòng)mock

__ _ _ mocks __ _ _/fetch.js
const actions = {
"GetAnnounce":{data:["公告公告公告","公告2公告2公告2"]}
}
export default function fetch(params){
return new Promise((resolve, reject) => {
const actionType = arams.url.substr('/common/'.length)
const res = actions[actionType];
process.nextTick(
() => res ? resolve(res) : reject({
error: 'action with ' + actionType + ' not found.',
})
);
})
}
fetch.js
import fetch from './fetch'
export function fetchNotice(params){
return fetch({
url:'/common/GetAnnounce',
params:params|{}
}).then(annouce => annouce)
}
export function getAllNotice(params){
return params || {}
}
annouce.js
import fetch from './fetch'
export function fetchNotice(params){
return fetch({
url:'/common/GetAnnounce',
params:params|{}
}).then(annouce => annouce)
}
export function getAllNotice(params){
return params || {}
}
annouce.test.js
jest.mock('services/fetch')
import {fetchNotice} from 'services/annouce'
describe('annouce',() => {
describe('獲取公告列表', () => {
it('正確返回公告數(shù)組', () => {
// expect([1,2]).toEqual([1,2])
return fetchNotice().then(res => expect(res.data).toHaveLength(2)).catch(err => console.log(err))
});
it('正確返回公告數(shù)組', () => {
// expect([1,2]).toEqual([1,2])
return fetchNotice().then(res => expect(res.data).toBeTruthy())
});
it('正確返回公告數(shù)組', () => {
// expect([1,2]).toEqual([1,2])
return fetchNotice().then(res => expect(res.data).toContain('公告公告公告'))
});
})
})
注意:
- ___mocks____文件夾要和要mock的方法放在同一級(jí)目錄。
2.如果mock的是nodejs方法,____ mocks ____文件夾要放在項(xiàng)目根目錄。