對 React 組件進行單元測試

前端開發的一個特色是更多的會涉及用戶界面,當開發規模達到必定程度時,幾乎註定了其複雜度會成倍的增加。javascript

不管是在代碼的初始搭建過程當中,仍是以後難以免的重構和修正bug過程當中,經常會陷入邏輯難以梳理、沒法掌握全局關聯的境地。php

而單元測試做爲一種「提綱挈領、保駕護航」的基礎手段,爲開發提供了「圍牆和腳手架」,能夠有效的改善這些問題。css

做爲一種經典的開發和重構手段,單元測試在軟件開發領域被普遍承認和採用;前端領域也逐漸積累起了豐富的測試框架和最佳實踐。html

本文將按以下順序進行說明:前端

  • I. 單元測試簡介
  • II. React 單元測試中用到的工具
  • III. 用測試驅動 React 組件重構
  • IV. React 單元測試常見案例

I. 單元測試簡介

單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。java

簡單來講,單元就是人爲規定的最小的被測功能模塊。單元測試是在軟件開發過程當中要進行的最低級別的測試活動,軟件的獨立單元將在與程序的其餘部分相隔離的狀況下進行測試。node

測試框架

測試框架的做用是提供一些方便的語法來描述測試用例,以及對用例進行分組。react

斷言(assertions)

斷言是單元測試框架中核心的部分,斷言失敗會致使測試不經過,或報告錯誤信息。webpack

對於常見的斷言,舉一些例子以下:es6

  • 同等性斷言 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",
    "react-hot-loader/babel"
  ],
  "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 中的 mountshallow 方法,將目標組件轉化爲一個 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. 添加一個測試
  2. 運行全部測試,看看新加的這個是否是失敗了;若是能成功則重複步驟1
  3. 根據失敗報錯,有針對性的編寫或改寫代碼;這一步的惟一目的就是經過測試,先沒必要糾結細節
  4. 再次運行測試;若是能成功則跳到步驟5,不然重複步驟3
  5. 重構已經經過測試的代碼,使其更可讀、更易維護,且不影響經過測試
  6. 重複步驟1

解讀測試覆蓋率

這就是 jest 內置的 istanbul 輸出的覆蓋率結果。

之因此叫作「伊斯坦布爾」,是由於土耳其地毯世界聞名,而地毯是用來"覆蓋"的🤦‍♀️。

表格中的第2列至第5列,分別對應四個衡量維度:

  • 語句覆蓋率(statement coverage):是否每一個語句都執行了
  • 分支覆蓋率(branch coverage):是否每一個if代碼塊都執行了
  • 函數覆蓋率(function coverage):是否每一個函數都調用了
  • 行覆蓋率(line coverage):是否每一行都執行了

測試結果根據覆蓋率被分爲「綠色、黃色、紅色」三種,應該視具體狀況儘可能提升相應模塊的測試覆蓋率。

優化依賴 讓 React 組件變得 testable

合理編寫組件化的 React,並將足夠獨立、功能專注的組件做爲測試的單元,將使得單元測試變得容易;

反之,測試的過程讓咱們更易釐清關係,將本來的組件重構或分解成更合理的結構。分離出的子組件每每也更容易寫成stateless的無狀態組件,使得性能和關注點更加優化。

明確指定 PropTypes

對於一些以前定義並不清晰的組件,能夠統一引入 prop-types,明確組件可接收的props;一方面能夠在開發/編譯過程當中隨時發現錯誤,另外也能夠在團隊中其餘成員引用組件時造成一個明晰的列表。

IV. React 單元測試常見案例

用例的預處理或後處理

能夠用beforeEachafterEach作一些統一的預置和藹後工做,在每一個用例的以前和以後都會自動調用:

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組件中一些操做須要延時進行,諸如onscrolloninput這類高頻觸發動做,須要作函數防抖或節流,好比經常使用的 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: "&yen;"
	}
};

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請求

在單元測試的過程當中,不免碰到一些須要遠程請求數據的狀況,好比組件獲取初始化數據、提交變化數據等。

要注意這種測試的目的仍是考察組件自己的表現,而非重點關心實際遠程數據的集成測試,因此咱們無需真實的請求,能夠簡單的模擬一些請求的場景。

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. 總結

單元測試做爲一種經典的開發和重構手段,在軟件開發領域被普遍承認和採用;前端領域也逐漸積累起了豐富的測試框架和方法。

單元測試能夠爲咱們的開發和維護提供基礎保障,使咱們在思路清晰、心中有底的狀況下完成對代碼的搭建和重構;

須要注意的是,世上沒有包治百病的良藥,單元測試也毫不是萬金油,秉持謹慎認真負責的態度才能從根本上保證咱們工做的進行。

擴展閱讀:

一個重構的實例 mp.weixin.qq.com/s?__biz=MzI…

VI. 參考資料

  • https://juejin.im/post/59b5e79f6fb9a00a600f4216
  • https://baike.baidu.com/item/單元測試
  • http://www.cnblogs.com/jianglingli83/archive/2013/03/15/2961847.html
  • http://blog.csdn.net/hustzw07/article/details/74178051
  • https://zhuanlan.zhihu.com/p/28247899
  • https://en.wikipedia.org/wiki/Test-driven_development
  • http://www.ruanyifeng.com/blog/2015/06/istanbul.html
  • https://www.eurobricks.com/forum/index.php?/forums/topic/52658-lego-construction-site/


(end)


長按二維碼或搜索 fewelife 關注咱們哦

相關文章
相關標籤/搜索