大多數開發者都知道需要寫單元測試,但是不知道每個單元測試應用的主要內容以及如何做單元測試,在介紹jest測試框架前,我們先來了解下一些測試相關的概念。
為什么需要單元測試?
- 保證質量:隨著迭代的過程,開發人員很難記清所有的功能點,功能點的新增和刪除在代碼改變后,進行回歸測試時,依靠人工QA很容易出錯遺漏。
- 自動化:通過編寫測試用例,只需要編寫一次,多次運行,同樣的事情不需要從頭再來測一遍,很多時候QA的工作量就是這么增加的,新的版本上線,人工QA都需要所有的功能點從新測試一遍。
- 特性文檔:單元測試可以作為描述和記錄代碼所實現的所有需求,有時候可以作為文檔來使用,了解一個項目可以通過閱讀測試用例比看需求文檔更清晰。
- 驅動開發,指導設計:代碼被測試的前提是代碼本身的可測試性,那么要保證代碼的可測試性,就需要在開發中注意API的設計,TDD將測試前移就是起到這么一個作用
測試類型
你可能接觸過各種測試框架、大體上,最重要測試類型有:
- 單元測試- 依靠模擬輸入證實是否是期望的輸出來分別的測試函數或者類。
- 集成測試 - 測試若干模塊來確保他們像預期的那樣工作。
- 功能測試- 在產品本身(例如在瀏覽器上)對一個場景進行操作,而不考慮內部結構以確保預期的行為。
測試工具類型
測試工具可以分為以下功能,有些提供一個功能,有些提供了一個組合。
使用工具組合是很常見的,即使你可以使用單一的工具實現同樣的功能,是所有組合可以獲得更靈活的功能。
- 測試環境(Mocha , Jasmine, Jest, Karma)
- 測試結構 (Mocha , Jasmine, Jest, Cucumber)
- 斷言函數(Chai, Jasmine, Jest, Unexpected)
- 生成,顯示、監聽測試結果(Mocha , Jasmine, JestKarma)
- 生成,比較組件和數據結構的快照,以確保之前運行的更改是預期的。(Jest,Ava)
- mocks。(sinon.js) 目前使用最多的mock庫,將其分為spies、stub、fake XMLHttpRequest、Fake server、Fake time幾種,根據不同的場景進行選擇。
- 生成代碼覆蓋率報告。(Istanbul, Jest)
- 瀏覽器或者類瀏覽器環境執行控制。(Protractor , Nightwatch, Phantom, Casper)
單元測試技術的實現原理
- 測試框架:判斷內部是否存在異常,存在則console出對應的text信息
- 斷言庫:當actual值與expect值不一樣時,就拋出異常,供外部測試框架檢測到,這就是為什么有些測試框架可以自由選擇斷言庫的原因,只要可以拋出異常,外部測試框架就可以工作。
- mock函數:創建一個新的函數,用這個函數來取代原來的函數,同時在這個新函數上添加一些額外的屬性,例如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); // 得到一個mock函數
測試用例的鉤子
describe塊之中,提供測試用例的四個鉤子:before()、after()、beforeEach()和afterEach()。它們會在指定時間執行。
describe('hooks', function() {
before(function() {
// 在本區塊的所有測試用例之前執行
});
after(function() {
// 在本區塊的所有測試用例之后執行
});
beforeEach(function() {
// 在本區塊的每個測試用例之前執行
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() {
// 在本區塊的每個測試用例之后執行
});
// 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');
});
});
如何寫單元測試用例
一些好的建議:
- 只考慮測試,不考慮內部實現
- 不要做無謂的斷言
- 讓每個單元測試保持獨立
- 所有的方法都應該寫單元測試
- 充分考慮數據的邊界條件
- 對重點、復雜、核心代碼,重點測試
- 利用AOP(beforeEach、afterEach),減少測試代碼數量,避免無用功能
- 使用最合適的斷言方式
TDD
一句話簡單來說,就是先寫測試,后寫功能實現。TDD的目的是通過測試用例來指引實際的功能開發,讓開發人員首先站在全局的視角來看待需求。具體定義可以查看維基;
BDD
行為驅動開發要求更多人員參與到軟件的開發中來,鼓勵開發者、QA、相關業務人員相互協作。BDD是由商業價值來驅動,通過用戶接口(例如GUI)理解應用程序。詳見維基.
<blockquote>
Jest介紹--Painless JavaScript Testing
Jest 是一款 Facebook 開源的 JS 單元測試框架,目前 Jest 已經在 Facebook 開源的 React, React Native 等前端項目中被做為標配測試框架。
Jest功能:
- 內置Jasmin語法
- 內置auto mock
- 自帶mock API
- 前端友好(集成JSDOM)
- 支持直接使用Promise和async/await書寫異步代碼
- 支持對 React 組件進行快照監控
- 擴展和集成 Babel 等常用工具集也很方便
- 自動環境隔離
Jest用法
安裝:
npm install --save-dev jest
package.json中添加:
{
"scripts": {
"test": "jest"
}
}
運行 npm test
也可通過命令行運行:
jest my-test --notify --config=config.json
附加配置
npm install --save-dev babel-jest regenerator-runtime
項目根目錄添加.babelrc文件
{
"presets": ["es2015", "react"]
}
Jest自動定義 NODE_ENV = test
測試腳本的寫法
下面是一個加法模塊add.js
的代碼。
// add.js
function add(x, y) {
return x + y;
}
module.exports = add;
要測試這個加法模塊是否正確,就要寫測試腳本。
通常,測試腳本與所要測試的源碼腳本同名,但是后綴名為.test.js
(表示測試)或者.spec.js
(表示規格)。比如,add.js
的測試腳本名字就是add.test.js
。
import add from '../src/add'
describe('加法函數測試', () => {
it('1加2應該等于3', () => {
expect(add(1, 2)).toBe(3);
});
});
測試腳本里面應該包括一個或多個describe塊,每個describe塊應該包括一個或多個it塊。
describe塊稱為"測試套件"(test suite),表示一組相關的測試。它是一個函數,第一個參數是測試套件的名稱("加法函數的測試"),第二個參數是一個實際執行的函數。
it塊稱為"測試用例"(test case),表示一個單獨的測試,是測試的最小單位。它也是一個函數,第一個參數是測試用例的名稱("1 加 1 應該等于 2"),第二個參數是一個實際執行的函數。
斷言庫的用法
expect(add(1, 2)).toBe(3);
所謂"斷言",就是判斷源碼的實際執行結果與預期結果是否一致,如果不一致就拋出一個錯誤。上面這句斷言的意思是,調用add(1, 1),結果應該等于2。
所有的測試用例(it塊)都應該含有一句或多句的斷言。它是編寫測試用例的關鍵。斷言功能由斷言庫來實現
簡單測試
// add.js
function add(a, b) {
return a + b;
}
module.exports = add;
// add.test.js
import add from '../src/add'
describe('加法函數測試', () => {
it('1加2應該等于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';
// 普通回調
it('the data is peanut butter', done => {
function callback(data) {
expect(data).toBe('peanut butter');
done();
}
fetchData(callback);
});
// 方法需要返回一個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');
})
手動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('正確返回公告數組', () => {
// expect([1,2]).toEqual([1,2])
return fetchNotice().then(res => expect(res.data).toHaveLength(2)).catch(err => console.log(err))
});
it('正確返回公告數組', () => {
// expect([1,2]).toEqual([1,2])
return fetchNotice().then(res => expect(res.data).toBeTruthy())
});
it('正確返回公告數組', () => {
// expect([1,2]).toEqual([1,2])
return fetchNotice().then(res => expect(res.data).toContain('公告公告公告'))
});
})
})
注意:
- ___mocks____文件夾要和要mock的方法放在同一級目錄。
2.如果mock的是nodejs方法,____ mocks ____文件夾要放在項目根目錄。