前端開發的一個特色是更多的會涉及用戶界面,當開發規模達到必定程度時,幾乎註定了其複雜度會成倍的增加。javascript
不管是在代碼的初始搭建過程當中,仍是以後難以免的重構和修正bug過程當中,經常會陷入邏輯難以梳理、沒法掌握全局關聯的境地。php
而單元測試做爲一種「提綱挈領、保駕護航」的基礎手段,爲開發提供了「圍牆和腳手架」,能夠有效的改善這些問題。css
做爲一種經典的開發和重構手段,單元測試在軟件開發領域被普遍承認和採用;前端領域也逐漸積累起了豐富的測試框架和最佳實踐。html
本文將按以下順序進行說明:前端
單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。java
簡單來講,單元
就是人爲規定的最小的被測功能模塊。單元測試是在軟件開發過程當中要進行的最低級別的測試活動,軟件的獨立單元將在與程序的其餘部分相隔離的狀況下進行測試。node
測試框架的做用是提供一些方便的語法來描述測試用例,以及對用例進行分組。react
斷言是單元測試框架中核心的部分,斷言失敗會致使測試不經過,或報告錯誤信息。webpack
對於常見的斷言,舉一些例子以下:es6
同等性斷言 Equality Asserts
比較性斷言 Comparison Asserts
類型性斷言 Type Asserts
條件性測試 Condition Test
斷言庫主要提供上述斷言的語義化方法,用於對參與測試的值作各類各樣的判斷。這些語義化方法會返回測試的結果,要麼成功、要麼失敗。常見的斷言庫有 Should.js, Chai.js 等。
爲某個特殊目標而編制的一組測試輸入、執行條件以及預期結果,以便測試某個程序路徑或覈實是否知足某個特定需求。
通常的形式爲:
it('should ...', function() {
...
expect(sth).toEqual(sth);
});
複製代碼
一般把一組相關的測試稱爲一個測試套件
通常的形式爲:
describe('test ...', function() {
it('should ...', function() { ... });
it('should ...', function() { ... });
...
});
複製代碼
正如
spy
字面的意思同樣,咱們用這種「間諜」來「監視」函數的調用狀況
經過對監視的函數進行包裝,能夠經過它清楚的知道該函數被調用過幾回、傳入什麼參數、返回什麼結果,甚至是拋出的異常狀況。
var spy = sinon.spy(MyComp.prototype, 'componentDidMount');
...
expect(spy.callCount).toEqual(1);
複製代碼
有時候會使用
stub
來嵌入或者直接替換掉一些代碼,來達到隔離的目的
一個stub
可使用最少的依賴方法來模擬該單元測試。好比一個方法可能依賴另外一個方法的執行,然後者對咱們來講是透明的。好的作法是使用stub 對它進行隔離替換。這樣就實現了更準確的單元測試。
var myObj = {
prop: function() {
return 'foo';
}
};
sinon.stub(myObj, 'prop').callsFake(function() {
return 'bar';
});
myObj.prop(); // 'bar'
複製代碼
mock
通常指在測試過程當中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來建立以便測試的測試方法
廣義的講,以上的 spy 和 stub 等,以及一些對模塊的模擬,對 ajax 返回值的模擬、對 timer 的模擬,都叫作 mock 。
用於統計測試用例對代碼的測試狀況,生成相應的報表,好比 istanbul
是常見的測試覆蓋率統計工具
不一樣於"傳統的"(其實也沒出現幾年)的 jasmine / Mocha / Chai 等前端測試框架 -- Jest
的使用更簡單,而且提供了更高的集成度、更豐富的功能。
Jest 是 Facebook 出品的一個測試框架,相對其餘測試框架,其一大特色就是就是內置了經常使用的測試工具,好比自帶斷言、測試覆蓋率工具,實現了開箱即用。
此外, Jest 的測試用例是並行執行的,並且只執行發生改變的文件所對應的測試,提高了測試速度。
編寫單元測試的語法一般很是簡單;對於jest
來講,因爲其內部使用了 Jasmine 2
來進行測試,故其用例語法與 Jasmine 相同。
實際上,只要先記這住四個單詞,就足以應付大多數測試狀況了:
describe
: 定義一個測試套件it
:定義一個測試用例expect
:斷言的判斷條件toEqual
:斷言的比較結果describe('test ...', function() {
it('should ...', function() {
expect(sth).toEqual(sth);
expect(sth.length).toEqual(1);
expect(sth > oth).toEqual(true);
});
});
複製代碼
Jest 號稱本身是一個 「Zero configuration testing platform」,只需在 npm scripts
裏面配置了test: jest
,便可運行npm test
,自動識別並測試符合其規則的(通常是 __test__
目錄下的)用例文件。
實際使用中,適當的自定義配置一下,會獲得更適合咱們的測試場景:
//jest.config.js
module.exports = {
modulePaths: [
"<rootDir>/src/"
],
moduleNameMapper: {
"\.(css|less)$": '<rootDir>/__test__/NullModule.js'
},
collectCoverage: true,
coverageDirectory: "<rootDir>/src/",
coveragePathIgnorePatterns: [
"<rootDir>/__test__/"
],
coverageReporters: ["text"],
};
複製代碼
在這個簡單的配置文件中,咱們指定了測試的「根目錄」,配置了覆蓋率(內置的istanbul
)的一些格式,並將本來在webpack中對樣式文件的引用指向了一個空模塊,從而跳過了這一對測試無傷大雅的環節
//NullModule.js
module.exports = {};
複製代碼
另外值得一提的是,因爲jest.config.js
是一個會在npm
腳本中被調用的普通 JS 文件,而非XXX.json
或.XXXrc
的形式,因此 nodejs 的各自操做均可以進行,好比引入 fs 進行預處理讀寫等,靈活性很是高,能夠很好的兼容各類項目
因爲是面向src
目錄下測試其React代碼,而且還使用了ES6語法,因此項目下須要存在一個.babelrc
文件:
{
"presets": ["env", "react"]
}
複製代碼
以上是基本的配置,而實際因爲webpack能夠編譯es6的模塊,通常將babel中設爲{ "modules": false }
,此時的配置爲:
//package.json
"scripts": {
"test": "cross-env NODE_ENV=test jest",
},
複製代碼
//.babelrc
{
"presets": [
["es2015", {"modules": false}],
"stage-1",
"react"
],
"plugins": [
"transform-decorators-legacy",
"react-hot-loader/babel"
],
"env": {
"test": {
"presets": [
"es2015", "stage-1", "react"
],
"plugins": [
"transform-decorators-legacy",
"react-hot-loader/babel"
]
}
}
}
複製代碼
Enzyme 來自於活躍在 JavaScript 開源社區的 Airbnb 公司,是對官方測試工具庫(react-addons-test-utils)的封裝。
這個單詞的倫敦讀音爲 ['enzaɪm]
,酵素或酶的意思,Airbnb 並無給它設計一個圖標,估計就是想取用它來分解 React 組件的意思吧。
它模擬了 jQuery 的 API,很是直觀而且易於使用和學習,提供了一些不同凡響的接口和幾個方法來減小測試的樣板代碼,方便判斷、操縱和遍歷 React Components 的輸出,而且減小了測試代碼和實現代碼之間的耦合。
通常使用 Enzyme 中的 mount
或 shallow
方法,將目標組件轉化爲一個 ReactWrapper
對象,並在測試中調用其各類方法:
import Enzyme,{ mount } from 'enzyme';
...
describe('test ...', function() {
it('should ...', function() {
wrapper = mount(
<MyComp isDisabled={true} />
);
expect( wrapper.find('input').exists() ).toBeTruthy();
});
});
複製代碼
圖中這位「我牽着馬」的並非捲簾大將沙悟淨...其實圖中的故事正是人所皆知的「特洛伊木馬」;大概意思就是希臘人圍困了特洛伊人十多年,久攻不下,心生一計,把營盤都撤了,只留下一個巨大的木馬(裏面裝着士兵),以及這位被扒光還被打得夠嗆的人,也就是此處要談的主角sinon,由他欺騙特洛伊人 --- 後面的劇情你們就都熟悉了。
因此這個命名的測試工具呢,也正是各類假裝滲透方法的合集,爲單元測試提供了獨立而豐富的 spy, stub 和 mock 方法,兼容各類測試框架。
雖然 Jest 自己也有一些實現 spy 等的手段,但 sinon 使用起來更加方便。
這裏不展開討論經典的 「測試驅動開發」(TDD - test driven development) 理論 -- 簡單的說,把測試正向加諸開發,先寫用例再逐步實現,就是TDD,這是很好理解的。
而當咱們反過頭來,對既有代碼補充測試用例,使其測試覆蓋率不斷提升,並在此過程當中改善原有設計,修復潛在問題,同時又保證原有接口不收影響,這種 TDD 行爲雖然沒人稱之爲「測試驅動重構」(test driven refactoring),但「重構」這個概念自己就包含了用測試保駕護航的意思,是必不可少的題中之意。
對於一些組件和共有函數等,完善的測試也是一種最好的使用說明書。
因爲測試結果中,成功的用例會用綠色表示,而失敗的部分會顯示爲紅色,因此單元測試也經常被稱爲 「Red/Green Testing」 或 「Red/Green Refactoring」 , 這也是 TDD 中的通常性步驟:
這就是 jest
內置的 istanbul
輸出的覆蓋率結果。
之因此叫作「伊斯坦布爾」,是由於土耳其地毯世界聞名,而地毯是用來"覆蓋"的🤦♀️。
表格中的第2列至第5列,分別對應四個衡量維度:
if
代碼塊都執行了測試結果根據覆蓋率被分爲「綠色、黃色、紅色」三種,應該視具體狀況儘可能提升相應模塊的測試覆蓋率。
合理編寫組件化的 React,並將足夠獨立、功能專注的組件做爲測試的單元,將使得單元測試變得容易;
反之,測試的過程讓咱們更易釐清關係,將本來的組件重構或分解成更合理的結構。分離出的子組件每每也更容易寫成stateless
的無狀態組件,使得性能和關注點更加優化。
對於一些以前定義並不清晰的組件,能夠統一引入 prop-types
,明確組件可接收的props
;一方面能夠在開發/編譯過程當中隨時發現錯誤,另外也能夠在團隊中其餘成員引用組件時造成一個明晰的列表。
能夠用beforeEach
和afterEach
作一些統一的預置和藹後工做,在每一個用例的以前和以後都會自動調用:
describe('test components/Comp', function() {
let wrapper;
let spy;
beforeEach(function() {
jest.useFakeTimers();
spy = sinon.spy(Comp.prototype, 'componentDidMount');
});
afterEach(function() {
jest.useRealTimers();
wrapper && wrapper.unmount();
didMountSpy.restore();
didMountSpy = null;
});
it('應該正確顯示基本結構', function() {
wrapper = mount(
<Comp ... /> ); expect(wrapper.find('a').text()).toEqual('HELLO!'); }); ... }); 複製代碼
對於一些組件中,若是但願在測試階段調用到其一些內部方法,又不想對原組件改動過大的,能夠用instance()
取得組件類實例:
it('應該正確獲取組件類實例', function() {
var wrapper = mount(
<MultiSelect
name="HELLOKITTY"
placeholder="select sth..." />
);
var wi = wrapper.instance();
expect( wi.props.name ).toEqual( "HELLOKITTY" );
expect( wi.state.open ).toEqual( false );
});
複製代碼
做爲UI組件,React組件中一些操做須要延時進行,諸如onscroll
或oninput
這類高頻觸發動做,須要作函數防抖或節流,好比經常使用的 lodash 的 debounce 等。
所謂的異步操做,在不考慮和 ajax 整合的集成測試的狀況下,通常都是指此類操做,只用 setTimeout 是不行的,須要搭配 done
函數使用:
//組件中
const Comp = (props)=>(
<input type="text" id="searchIpt" onChange={ debounce(props.onSearch, 500) } /> ); 複製代碼
//單元測試中
it('應該在輸入時觸發回調', function(done) {
var spy = jest.fn();
var wrapper = mount(
<Comp onChange={ spy } /> ); wrapper.find('#searchIpt').simulate('change'); setTimeout(()=>{ expect( spy ).toHaveBeenCalledTimes( 1 ); done(); }, 550); }); 複製代碼
一些模塊中可能耦合了對 window.xxx
這類全局對象的引用,而徹底去實例化這個對象可能又牽扯出不少其餘的問題,難以進行;此時能夠見招拆招,只模擬一個最小化的全局對象,保證測試的進行:
//fakeAppFacade.js
var facade = {
router: {
current: function() {
return {name:null, params:null};
}
}, appData: {
symbol: "¥"
}
};
window._appFacade = facade;
module.exports = facade;
複製代碼
//測試套件中
import fakeFak from '../fakeAppFacade';
複製代碼
另外好比 LocalStroage 這類對象,測試端環境中沒有原生支持,也能夠簡單模擬一下:
//fakeStorage.js
var _util = {};
var fakeStorage = {
"set": function(k, v) {
_util['_fakeSave_'+k] = v;
},
"get": function(k) {
return _util['_fakeSave_'+k] || null;
},
"remove": function(k) {
delete _util['_fakeSave_'+k];
},
"has": function(k) {
return _util.hasOwnProperty('_fakeSave_'+k);
}
};
module.exports = fakeStorage;
複製代碼
在一個項目中用到了 react-bootstrap
界面庫,測試一個組件時,因爲包含了其 Modal
模態彈窗,而彈窗組件是默認渲染到 document
中的,致使難以用普通的 find
方法等獲取
解決的辦法是模擬一個渲染到容器組件原處的普通組件:
//FakeReactBootstrapModal.js
import React, {Component} from 'react';
class FakeReactBootstrapModal extends Component {
constructor(props) {
super(props);
}
render() { //原生的 react-bootstrap/Modal 沒法被 enzyme 測試
const {
show,
bgSize,
dialogClassName,
children
} = this.props;
return show
? <div className={
`fakeModal ${bgSize} ${dialogClassName}`
}>{children}</div>
: null;
}
}
export default FakeReactBootstrapModal;
複製代碼
同時在組件渲染時,加入判斷邏輯,使之能夠支持自定義的類代替 Modal 類:
//ModalComp.js
import { Modal } from 'react-bootstrap';
...
render() {
const MyModal = this._modalClass || Modal;
return (<MyModal bsSize={props.mode>1 ? "large" : "middle"} dialogClassName="custom-modal"> ... </MyModal>;
}
複製代碼
而測試套件中,實現一個測試專用的子類:
//myModal.spec.js
import ModalComp from 'components/ModalComp';
class TestModalComp extends ModalComp {
constructor(props) {
super(props);
this._modalClass = FakeReactBootstrapModal;
}
}
複製代碼
這樣測試便可順利進行,跳過了並不重要的 UI 效果,而各類邏輯都能被覆蓋了
在單元測試的過程當中,不免碰到一些須要遠程請求數據的狀況,好比組件獲取初始化數據、提交變化數據等。
要注意這種測試的目的仍是考察組件自己的表現,而非重點關心實際遠程數據的集成測試,因此咱們無需真實的請求,能夠簡單的模擬一些請求的場景。
sinon 中有一些模擬 XMLHttpRequest 請求的方法, jest 也有一些第三方的庫解決 fetch 的測試;
在咱們的項目中,根據實際的用法,本身實現一個類來模擬請求的響應:
//FakeFetch.js
import { noop } from 'lodash';
const fakeFetch = (jsonResult, isSuccess=true, callback=noop)=>{
const blob = new Blob(
[JSON.stringify(jsonResult)],
{type : 'application/json'}
);
return (...args)=>{
console.log('FAKE FETCH', args);
callback.call(null, args);
return isSuccess
? Promise.resolve(
new Response(
blob,
{status:200, statusText:"OK"}
)
)
: Promise.reject(
new Response(
blob,
{status:400, statusText:"Bad Request"}
)
)
}
};
export default fakeFetch;
複製代碼
//Comp.spec.js
import fakeFetch from '../FakeFetch';
const _fc = window.fetch; //緩存「真實的」fetch
describe('test components/Comp', function() {
let wrapper;
afterEach(function() {
wrapper && wrapper.unmount();
window.fetch = _fc; //恢復
});
it("應該在遠程請求時響應onRemoteData", (done)=>{
window.fetch = fakeFetch({
brand: "GoBelieve",
tree: {
node: '總部',
children: null
}
});
let spy = jest.fn();
wrapper = mount(
<Comp onRemoteData={ spy } />
);
jest.useRealTimers();
_clickTrigger(); //此時應該發起請求
setTimeout(()=>{
expect(wrapper.html()).toMatch(/總部/);
expect(spy).toHaveBeenCalledTimes(1);
done();
}, 500);
});
});
複製代碼
單元測試做爲一種經典的開發和重構手段,在軟件開發領域被普遍承認和採用;前端領域也逐漸積累起了豐富的測試框架和方法。
單元測試能夠爲咱們的開發和維護提供基礎保障,使咱們在思路清晰、心中有底的狀況下完成對代碼的搭建和重構;
須要注意的是,世上沒有包治百病的良藥,單元測試也毫不是萬金油,秉持謹慎認真負責的態度才能從根本上保證咱們工做的進行。
一個重構的實例 mp.weixin.qq.com/s?__biz=MzI…
長按二維碼或搜索 fewelife 關注咱們哦