在這裏說一下前端開發的一個特點是更多的會涉及用戶界面,當開發規模達到一定程度時,幾乎註定了其複雜度會成倍的增長。
無論是在代碼的初始搭建過程中,還是之後難以避免的重構和修正bug過程中,常常會陷入邏輯難以梳理、無法掌握全局關聯的境地。
而單元測試作爲一種“提綱挈領、保駕護航”的基礎手段,爲開發提供了“圍牆和腳手架”,可以有效的改善這些問題。
作爲一種經典的開發和重構手段,單元測試在軟件開發領域被廣泛認可和採用;前端領域也逐漸積累起了豐富的測試框架和最佳實踐。
本文將按如下順序進行說明:
- I. 單元測試簡介
- II. React 單元測試中用到的工具
- III. 用測試驅動 React 組件重構
- IV. React 單元測試常見案例
I. 單元測試簡介
單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。
簡單來說,單元就是人爲規定的最小的被測功能模塊。單元測試是在軟件開發過程中要進行的最低級別的測試活動,軟件的獨立單元將在與程序的其他部分相隔離的情況下進行測試。
測試框架
測試框架的作用是提供一些方便的語法來描述測試用例,以及對用例進行分組。
斷言(assertions)
斷言是單元測試框架中核心的部分,斷言失敗會導致測試不通過,或報告錯誤信息。
對於常見的斷言,舉一些例子如下:
-
同等性斷言 Equality Asserts
- expect(sth).toEqual(value)
- expect(sth).not.toEqual(value)
-
比較性斷言 Comparison Asserts
- expect(sth).toBeGreaterThan(number)
- expect(sth).toBeLessThanOrEqual(number)
-
類型性斷言 Type Asserts
- expect(sth).toBeInstanceOf(Class)
-
條件性測試 Condition Test
- expect(sth).toBeTruthy()
- expect(sth).toBeFalsy()
- expect(sth).toBeDefined()
斷言庫
斷言庫主要提供上述斷言的語義化方法,用於對參與測試的值做各種各樣的判斷。這些語義化方法會返回測試的結果,要麼成功、要麼失敗。常見的斷言庫有 Should.js, Chai.js 等。
測試用例 test case
爲某個特殊目標而編制的一組測試輸入、執行條件以及預期結果,以便測試某個程序路徑或覈實是否滿足某個特定需求。
一般的形式爲:
it('should ...', function() {
...
expect(sth).toEqual(sth);
});
測試套件 test suite
通常把一組相關的測試稱爲一個測試套件
一般的形式爲:
describe('test ...', function() {
it('should ...', function() { ... });
it('should ...', function() { ... });
...
});
spy
正如
spy
字面的意思一樣,我們用這種“間諜”來“監視”函數的調用情況
通過對監視的函數進行包裝,可以通過它清楚的知道該函數被調用過幾次、傳入什麼參數、返回什麼結果,甚至是拋出的異常情況。
var spy = sinon.spy(MyComp.prototype, 'componentDidMount');
...
expect(spy.callCount).toEqual(1);
stub
有時候會使用
stub
來嵌入或者直接替換掉一些代碼,來達到隔離的目的
一個stub
可以使用最少的依賴方法來模擬該單元測試。比如一個方法可能依賴另一個方法的執行,而後者對我們來說是透明的。好的做法是使用stub 對它進行隔離替換。這樣就實現了更準確的單元測試。
var myObj = {
prop: function() {
return 'foo';
}
};
sinon.stub(myObj, 'prop').callsFake(function() {
return 'bar';
});
myObj.prop(); // 'bar'
mock
mock
一般指在測試過程中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來創建以便測試的測試方法
廣義的講,以上的 spy 和 stub 等,以及一些對模塊的模擬,對 ajax 返回值的模擬、對 timer 的模擬,都叫做 mock 。
測試覆蓋率(code coverage)
用於統計測試用例對代碼的測試情況,生成相應的報表,比如 istanbul
是常見的測試覆蓋率統計工具
II. React 單元測試中用到的工具
Jest
不同於"傳統的"(其實也沒出現幾年)的 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 進行預處理讀寫等,靈活性非常高,可以很好的兼容各種項目
babel-jest
由於是面向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", 如果對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。
"react-hot-loader/babel" 感興趣可以175317069,羣內會有不定期的發放免費的資料鏈接,這些資料都是從各
], 個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處
"env": { 之後分享給大家。
"test": {
"presets": [
"es2015", "stage-1", "react"
],
"plugins": [
"transform-decorators-legacy",
"react-hot-loader/babel"
]
}
}
}
Enzyme
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
圖中這位“我牽着馬”的並不是捲簾大將沙悟淨...其實圖中的故事正是人所皆知的“特洛伊木馬”;大概意思就是希臘人圍困了特洛伊人十多年,久攻不下,心生一計,把營盤都撤了,只留下一個巨大的木馬(裏面裝着士兵),以及這位被扒光還被打得夠嗆的人,也就是此處要談的主角sinon,由他欺騙特洛伊人 --- 後面的劇情大家就都熟悉了。
所以這個命名的測試工具呢,也正是各種僞裝滲透方法的合集,爲單元測試提供了獨立而豐富的 spy, stub 和 mock 方法,兼容各種測試框架。
雖然 Jest 本身也有一些實現 spy 等的手段,但 sinon 使用起來更加方便。
III. 用測試驅動 React 組件重構
這裏不展開討論經典的 “測試驅動開發”(TDD - test driven development) 理論 -- 簡單的說,把測試正向加諸開發,先寫用例再逐步實現,就是TDD,這是很好理解的。
而當我們反過頭來,對既有代碼補充測試用例,使其測試覆蓋率不斷提高,並在此過程中改善原有設計,修復潛在問題,同時又保證原有接口不收影響,這種 TDD 行爲雖然沒人稱之爲“測試驅動重構”(test driven refactoring),但“重構”這個概念本身就包含了用測試保駕護航的意思,是必不可少的題中之意。
對於一些組件和共有函數等,完善的測試也是一種最好的使用說明書。
失敗-編碼-通過 三部曲
由於測試結果中,成功的用例會用綠色表示,而失敗的部分會顯示爲紅色,所以單元測試也常常被稱爲 “Red/Green Testing” 或 “Red/Green Refactoring” , 這也是 TDD 中的一般性步驟:
- 添加一個測試
- 運行所有測試,看看新加的這個是不是失敗了;如果能成功則重複步驟1
- 根據失敗報錯,有針對性的編寫或改寫代碼;這一步的唯一目的就是通過測試,先不必糾結細節
- 再次運行測試;如果能成功則跳到步驟5,否則重複步驟3
- 重構已經通過測試的代碼,使其更可讀、更易維護,且不影響通過測試
- 重複步驟1
如果對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。感興趣可以175317069,羣內會有不定期的發放免費的資料鏈接,這些資料都是從各個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處之後分享給大家。
解讀測試覆蓋率
這就是 jest
內置的 istanbul
輸出的覆蓋率結果。
之所以叫做“伊斯坦布爾”,是因爲土耳其地毯世界聞名,而地毯是用來"覆蓋"的♀️。
表格中的第2列至第5列,分別對應四個衡量維度:
- 語句覆蓋率(statement coverage):是否每個語句都執行了
- 分支覆蓋率(branch coverage):是否每個
if
代碼塊都執行了 - 函數覆蓋率(function coverage):是否每個函數都調用了
- 行覆蓋率(line coverage):是否每一行都執行了
測試結果根據覆蓋率被分爲“綠色、黃色、紅色”三種,應該視具體情況儘量提高相應模塊的測試覆蓋率。
優化依賴 讓 React 組件變得 testable
合理編寫組件化的 React,並將足夠獨立、功能專一的組件作爲測試的單元,將使得單元測試變得容易;
反之,測試的過程讓我們更易釐清關係,將原本的組件重構或分解成更合理的結構。分離出的子組件往往也更容易寫成stateless
的無狀態組件,使得性能和關注點更加優化。
明確指定 PropTypes
對於一些之前定義並不清晰的組件,可以統一引入 prop-types
,明確組件可接收的props
;一方面可以在開發/編譯過程中隨時發現錯誤,另外也可以在團隊中其他成員引用組件時形成一個明晰的列表。
IV. React 單元測試常見案例
用例的預處理或後處理
可以用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
在一個項目中用到了 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 效果,而各種邏輯都能被覆蓋了
模擬fetch請求
如果對軟件測試、接口測試、自動化測試、性能測試、LR腳本開發、面試經驗交流。感興趣可以175317069,羣內會有不定期的發放免費的資料鏈接,這些資料都是從各個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處之後分享給大家。
在單元測試的過程中,難免碰到一些需要遠程請求數據的情況,比如組件獲取初始化數據、提交變化數據等。
要注意這種測試的目的還是考察組件本身的表現,而非重點關心實際遠程數據的集成測試,所以我們無需真實的請求,可以簡單的模擬一些請求的場景。
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);
});
});
V. 總結
單元測試作爲一種經典的開發和重構手段,在軟件開發領域被廣泛認可和採用;前端領域也逐漸積累起了豐富的測試框架和方法。
單元測試可以爲我們的開發和維護提供基礎保障,使我們在思路清晰、心中有底的情況下完成對代碼的搭建和重構;
需要注意的是,世上沒有包治百病的良藥,單元測試也絕不是萬金油,秉持謹慎認真負責的態度才能從根本上保證我們工作的進行。