Jest & enzyme 進行react單元測試
在撰寫單元測試用例之前,我們需要了解到撰寫測試用例的原因。 寫測試用例的目的在於保證程式碼的迭代安全,並不是為了100%的coverage或者是case pass,coverage和case僅僅是為了實現程式碼安全的因素。
單元測試(Unit Test):前端單元測試,在以前也許是一個比較陌生的工作,但是前端在經歷了這幾年的發展之後,我們對於程式碼的魯棒性要求逐漸提升,承載了更多的業務邏輯的同時,作為整個鏈路上最接近使用者的部分,系統崩潰阻塞的成本非常之高。如果你採用的是SSR,那麼直接在服務端渲染報錯則是更為致命的。
前端的單元測試能夠在一定程度上保證:
- 在迭代過程中保證每次提交的程式碼的質量;
- 在程式碼的重構過程中,原始功能的完整性;
- 每次程式碼迭代的副作用可控;
相對於後端程式碼來說,前端程式碼更多地會涉及到DOM相關的內容,對於非結構化的內容如何進行測試呢?
airbnb提供了一個比較合適的React單元測試解決方案,結合Jest以及husky,可以保證每次commit的程式碼都符合規範,並且coverage內的程式碼功能完整。
UT之於library
庫對於單元測試的要求是非常高的。因為一個lib可能被多個業務線以及工程所引入, 一旦這個lib出現了任何問題,影響到的範圍是非常大的 。我們又不可能要求QA對於多個業務線進行迴歸(怕是他們要殺了我們祭天吧)。
為了保證lib的迭代不會影響到原有的業務功能,單元測試是一個非常好的方法。由於我們主要的技術棧還是基於React的各種解決方案,所以有比較多的業務元件以及公共元件,這些元件被多個業務線使用。lerna架構的元件工程在每次commit的時候都會跑UT,來進行功能迴歸。
UT之於業務
業務程式碼一般對於單元測試的需求並不如lib那樣高,但是在某些核心業務邏輯中接入UT,也是可以保證程式碼整體的質量的。最起碼可以保證業務程式碼在正常的渲染過程中不發生報錯。
框架
前面簡單描述了一下單元測試對於前端程式碼的重要性,很多人說現在的前端圈子和娛樂圈一樣,確實,目前可選的測試框架林林總總有很多,經歷了jasmine、mocha,現在來到了Jest。
TL;DR
9102年了, Jest 可以說是目前前端最好的測試框架了。可以進行快速配置,和enzyme很好地結合,能夠保證在React技術棧中,快速跑起來一個測試用例。
但是,最吸引人的還是其內建的coverage報告,可以快速生成程式碼覆蓋率。
相比於測試框架,React的測試庫似乎沒有什麼其他的選擇了, enzyme 基本可以滿足任何前端的測試需求。但是對於非同步強互動的頁面來說,撰寫測試用例的學習成本還是比較高的。
技術棧
最終我們為了各種場景下React的單元測試,集成了下面的lib:
- Jest:單元測試框架
- enzyme: React測試庫
- Nock: 非同步請求模擬
- Async-wait-until: 非同步操作結束通知
- Husky: pre-commit階段執行單元測試
配置
Jest
Jest本身就以配置簡單著稱,而enzyme更是可以即插即用的測試庫。所以配置過程要比較輕鬆。
module.exports = { // 單元測試環境根目錄 rootDir: path.resolve(__dirname), // 指定需要進行單元測試的檔案匹配規則 testMatch: [ '<rootDir>/test/**/__test__/*.js' ], // 需要忽略的檔案匹配規則 testPathIgnorePatterns: [ '/node/modules' ], testURL: 'http://localhost/', // 是否收集測試覆蓋率,以及覆蓋率檔案路徑 collectCoverage: true, coverageDirectory: './coverage' }; 複製程式碼
上面是幾個比較重要的配置項。其中大部分都是比較好理解的,而 testURL
這個配置項需要說明一下, 這個規則表示當前測試用例所執行的URL ,雖然測試的時候我們看不到完整的頁面,但是測試用例本身是掛載到一個頁面中的,而這個頁面的URL就是通過 testURL
指定的。
在這個Jest配置下,所有的測試用例中,如果執行 location.href
都會拿到 http://localhost/
這個URL的,這個配置項在進行需要網路請求的case中是很關鍵的。
在執行的時候,可以指定Jest的配置檔案路徑:
~ jest --config ./scripts/jest.config.js 複製程式碼
如果沒有指定檔案路徑的話,預設則是取當前檔案路徑的配置檔案。
enzyme
enzyme本身是不需要配置的,作為一個即插即用的React測試庫,也算是讓我們前端脫離了 配置工程師 的苦海。
但是基於React進行開發,則需要安裝對應的React Adapter,比如如果你需要使用 static getDerivedStateFromProps
方法,那麼就需要引入 enzyme-adapter-react-16
的庫來保證enzyme渲染的版本和你使用的版本是一致的。
Jest在進行UT的過程中,會首先檢查工程是否有配置 .babelrc
檔案,如果配置了,則會自動根據這個檔案來進行babel編輯,然後執行測試用例。
一個隨手搭建的演示環境的依賴:
"dependencies": { "react": "^16.7.0", "react-dom": "^16.7.0" }, "devDependencies": { "babel-plugin-transform-async-to-generator": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1", "babel-preset-env": "^1.7.0", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "babel-preset-stage-3": "^6.24.1", "enzyme-adapter-react-16": "^1.7.1", "enzyme": "^3.8.0", "jest": "^23.6.0" }, "scripts": { "test": "jest --config ./jest.config.js" } 複製程式碼
// ./__test__/index.js import Test from '../src'; import Enzyme, { shallow, render, mount } from 'enzyme'; import React from 'react'; import Adapter from 'enzyme-adapter-react-16'; Enzyme.configure({ adapter: new Adapter() }); 複製程式碼
而enzyme的adapter是需要進行初始化的,通過 Enzyme.configure
指定需要引入的adapter例項。
這樣就完成了一個Enzyme + React + Jest的環境。
撰寫一個簡單的測試用例
斷言
目前,各種測試框架的斷言已經開始收斂,Jest採用的斷言語法和我們之前使用的mocha語法類似。
一個test suite可以用 describe
來描述,一個test suite可以包含多個case,來測試各種場景下的元件渲染結果。
我們先給出一個非常簡單的React元件:
import React from 'react'; export default class Text extends React.Component { render() { return (<div className="test-container" />) }; } 複製程式碼
對於這個元件,我們需要判斷是否成功渲染出來了div元素,並且元素的類名是 test-container
。
這是一個極簡版本的case:
describe('test suite: Test component', () => { it('case: expect Test render a div with className: test-container', () => { const wrapper = shallow(<Test />); expect(wrapper.find('.test-container').length).toEqual(1); }); }); 複製程式碼

可以看到suites和cases的通過情況,以及各種覆蓋率結果。其實前端單元測試也可以這麼簡單的。
關於enzyme的三個核心渲染方法,mount、render以及shallow,網上有很多文章介紹三者之間的區別,這裡就不班門弄斧了。mount應該是我寫測試用例最常用的方法吧,畢竟大部分元件的邏輯都需要真實掛載出來,才能夠進行用例測試。
測試用例也可以很複雜
最近有一個比較複雜的元件,需要接入單元測試,當時在開發的時候太天真,現在想起來真的是追悔莫及。元件內部包含: fetch請求、時間獲取、 history
操作,並且含有非常多的人機互動邏輯 。
這樣的元件現在想起來是非常不規範的,但是為了保證以後修改的時候,業務邏輯的魯棒,也不得不強行為其新增單元測試。
下面有很多case,大部分case都是在實際coding過程中遇到的,希望能夠幫助到有同樣需求的人。
history和Date.now()
在業務程式碼中,很多時候我們都需要進行頁面的跳轉,或者hash的修改。所有對於 location
的操作都會落在 window.location
的物件上。
enzyme實際上為我們構建了一個虛擬的DOM環境,我們可以拿到對應的DOM元素以及 window
、 document
物件來進行DOM操作。
Date
也是類似的,也是一個全域性的物件,以前我們通過整合 js-dom
來進行模擬,而現在enzyme和Jest為我們做好了這些工作。
看下面這個元件:
class Time extends React.Component { static propTypes = { time: PropTypes.number }; constructor(props) { super(props); this.state = { before: Date.now() < props.time } } render() { const { before } = this.state; const { time } = this.props; if (before) { return ( <div className="before"> {`now is before time: ${time}`} </div> ); } else { return ( <div className="after"> {`now is after time: ${time}`} </div> ); } } } 複製程式碼
在撰寫單元測試的時候,我們會發現,由於當前時間的不一致,所以作為 props
傳入的時間在和 Date.now()
進行比較,得到的結果是不一致的,這樣會導致測試用例的結果不可控。
為了保證 Date.now()
得到的值是一致的,我們需要改寫DOM上的 Date
物件。
describe('test suite: Time component', () => { const NOW_TO_CACHE = global.Date.now; const NOW_TO_USE = jest.fn(() => 1547717952668); beforeEach(() => { global.Date.now = NOW_TO_USE; }); afterEach(() => { global.Date.now = NOW_TO_CACHE; }); it('case: now is less than props\' time', () => { const wrapper = shallow(<Time time={1547717952669} />); console.log(Date.now()) expect(wrapper.find('.before').length).toEqual(1); }); it('case: now is greater than props\' time', () => { const wrapper = shallow(<Time time={1547717952667} />); console.log(Date.now()) expect(wrapper.find('.after').length).toEqual(1); }) }); 複製程式碼
beforeEach
和 afterEach
兩個hook在每一個case執行之前或者之後,會分別執行,在每個case之前,進行 global.Date.now
的改寫,然後在case結束之後,將 global.Date.now
恢復為原本的方法。
jest.fn
會生成一個Mock函式,這個函式和其他函式不一樣的地方在於,這個函式會記錄到其被執行的一些資訊,比如:
this

可以看到,對於所有的 Date.now()
方法,得到的當前時間都被複寫成了一個確定的數字,這樣就可以保證你的測試用例的時間無關性。
對於 history
、 Date.now
這類掛載到 window
或者 document
上面的例項物件,我們都可以通過 jest.fn
來複寫其方法,保證這些方法被呼叫的順序以及呼叫結果的正確性,我們也可以在 jest.fn
內部進行斷言,從而判斷每次執行的過程中是否發生錯誤。
fetch請求
前端作為View,部分場景下比較依賴後端提供的Model來進行渲染,API的正確性很多時候會直接影響到整個頁面的渲染結果是否正確。
並且部分場景中,某些程式碼也許是在 Promise
被 resolve
了之後才會被呼叫。
所以我們需要模擬fetch請求,來保證在請求回撥中的程式碼被單元測試覆蓋到。
這裡就需要用到:
Nock:HTTP server mocking and expectations library for Node.js Async-wait-until:Wait while predicate completes and resolve a Promise
這兩個庫了。
首先,看下面這個元件:
import React from 'react'; import fetch from 'isomorphic-fetch'; export default class AsyncComponent extends React.Component { constructor(props) { super(props); this.state = { user: {} } } componentDidMount() { this.fetchUser() .then(res => { this.setState({user: res}); }); } fetchUser = () => { return fetch(`${location.origin}/api/user/get`, { method: 'GET' }).then(ret => { return ret.json(); }).catch(err => { console.error(err); }); } render() { const { user } = this.state; return ( <div className="user-profile"> <p className="name">{user.name}</p> <p className="age">{user.age}</p> </div> ); } } 複製程式碼
元件內部在 componentDidMount
階段進行了一次fetch請求,來在客戶端渲染的時候獲取資料,填充到頁面中。
同步的測試工作非常簡單,根據前面的幾個例子,相信你可以對於渲染進行很好地測試了。
Q & A:
Q:其一:如何測試網路請求的回撥呢?
我們不可能直接將UT的請求直接打到後臺的接口裡,這樣在沒有網路的環境下,UT是通過不了的。所以必須要在本地模擬到近似於真實的網路請求。
A:Nock
Q: 其二:網路請求時非同步的,如果撰寫非同步的測試用例呢?
元件View的更新是在非同步的請求resolve之後進行的,而測試用例的執行是同步的,這樣就會出現時序問題,所以我們需要將斷言和元件的fetch同步執行。
A: async-wait-until
這就是我們引入這兩個庫的原因了。具體如何結合這兩個庫來進行非同步渲染的單元測試,看下面這個test suite。
import Async from '../src/async'; import Enzyme, { shallow, render, mount } from 'enzyme'; import React from 'react'; import Adapter from 'enzyme-adapter-react-16'; import nock from 'nock'; import waitUntil from 'async-wait-until'; Enzyme.configure({ adapter: new Adapter() }); describe('test suite: Async component', () => { beforeAll(() => { nock('http://localhost/api/user') .get('/get') .reply(200, { "name": "lucas", "age": 20 }); }); afterAll(() => { nock.cleanAll(); }); it('case: expect component did mount will trigger re-render', async () => { const wrapper = mount(<Async />); await waitUntil(() => wrapper.state('user').name === 'lucas'); expect(wrapper.find('.name').text()).toBe('lucas'); expect(wrapper.find('.age').text()).toBe('20'); }); }); 複製程式碼
上面的這個測試用例的核心在於模擬fetch請求,並且等在請求結束再執行對應的斷言。
首先,我們為這個test suite增加了兩個hook, beforeAll
會在這個suite的所有case執行之前執行一次,而 afterAll
則會在所有的case全部執行完之後,執行一次。
beforeAll
中,我們通過nock模擬了元件中fetch請求的請求結果,給到了一個resolve的響應。
當React執行到 componentDidMount
的時候,會進行fetch請求,這個請求會被打到nock中。這裡注意到,我們fetch的URL是 http://localhost/api/user/get
,這就是之前提到的,Jest配置項中設定 testURL
的作用。 testURL
指定的URL會作為測試頁面的 location.origin
。
由於fetch是一個非同步的過程,我們需要等待fetch被resolve之後,才能夠進行斷言。
所以,這裡用到了 waitUntil
,這個函式接受一個函式作為引數,這個函式會返回一個bool值,當bool值為 true
的時候,表示非同步呼叫結束,可以開始執行後面的邏輯了,當然,我們也可以封裝一個自己的 waitUntil
,其本質就是封裝一個Promise。
結束了這一個suite之後,程式碼邏輯會走到 afterAll
的hook中。這裡面呼叫了 nock.cleanAll()
,用於對之前mock的介面進行清理,也就是規範這個mock的作用域僅僅位於當前的suite中。
這時,我們再跑一次 npm run test
,可以得到下面的測試結果:

結合上面的test suite,在單元測試中成功進行了fetch,並且渲染出了正確的結果。
但是細心的小夥伴可能會發現,coverage報告中有一行程式碼沒有被這個test suite覆蓋到,這行程式碼可以定位到fetch的reject中,因為我們僅僅測試了fetch resolve的情況。
為了測試reject的情況,我們需要一個新的suite,在這個suite中,我們mock一個reject響應的介面:
describe('test suite: Async component', () => { let resolve = false; beforeAll(() => { nock('http://localhost/api/user') .get('/get') .reply(400, () => { resolve = true; }); }); afterAll(() => { nock.cleanAll(); }); it('case: expect component fetch error will not block rendering', async () => { const wrapper = mount(<Async />); await waitUntil(() => resolve); expect(wrapper.find('.name').text()).toBe(''); expect(wrapper.find('.age').text()).toBe(''); }); }); 複製程式碼
由於請求是非同步的,並且與resolve的情況不同,我們不知道何時請求會被reject,所以我們需要給nock傳入一個回撥,來標識fetch結束,請求被reject。
這樣就可以測試到reject情況下頁面是否成功渲染了,保證了各種condition下,頁面或者元件的穩定。

互動模擬
作為鏈路中toC的部分,前端程式碼中有許多地方是需要進行人機互動的。在互動過程中,javascript主要以註冊事件的方式進行互動響應。
人機互動不僅僅是非同步的,並且還包含事件的觸發以及回撥。這部分測試,enzyme提供了很多有意思的API,來幫助我們完成人機互動過程的單元測試。
考慮下面的這個元件:
import React from 'react'; import fetch from 'isomorphic-fetch'; export default class Text extends React.Component { constructor(props) { super(props); this.state = { value: '' }; } onInputChanged = (e) => { this.setState({ value: e.target.value }); } onClicked = () => { const { value } = this.state; this.postValue(value) .then(res => { this.setState({ value: '' }); }); } postValue = (value) => { return fetch(`${location.origin}/api/value`, { method: 'POST', body: JSON.stringify({value}), }).then(ret => { return ret.json(); }); } render() { const { value } = this.state; return ( <div className="form"> <input value={value} onChange={this.onInputChanged} /> <button className="submit" onClick={this.onClicked}>提交</button> </div> ) } } 複製程式碼
這是一個常見的React輸入框,我們將輸入框的 value
繫結到 state
上面。期望能夠通過使用者輸入來改變元件狀態,在使用者點選提交的時候,可以從頁面中取到這個值,並且POST到服務端,在得到了正確的回撥之後,清空掉輸入框中的內容。
這種需求比較普遍,現在需要為這樣一個需求新增一組單元測試,保證這個元件能夠穩定執行。
考慮到幾個重點:
- 觸發輸入框onchange事件
- 等待輸入框輸入事件結束
- 觸發按鈕點選事件
- 進行fetch
- 等待fetch結束
- 回撥中清理input內容
enzyme提供了一些觸發事件的方法。當我們使用 mount
將一個元件掛載到虛擬DOM上的時候,可以通過 wrapper.simulate()
方法來觸發各種DOM事件。
首先,先測試元件是否正確完成渲染:
it('case: expect input & click operation correct', async () => { const wrapper = mount(<Interaction />); const input = wrapper.find('input').at(0); const button = wrapper.find('button').at(0); expect(input.exists()); expect(button.exists()); }); 複製程式碼
然後需要觸發input的onchange事件,來改變當前的state:
input.simulate('change', { target: { value: 'lucas' } }); expect(wrapper.state('value')).toBe('lucas'); 複製程式碼
接著,觸發按鈕的點選事件,進行fetch請求,然後在響應返回之後,清理掉 state
中的內容。
button.simulate('click'); 複製程式碼
這樣就完成了整個元件的操作流程的UT了,執行這個單元測試,可以發現我們的測試已經完全覆蓋了所有程式碼的所有分支了。
下面是完成的test suite:
import Interaction from '../src/interaction'; import Enzyme, { shallow, render, mount } from 'enzyme'; import React from 'react'; import Adapter from 'enzyme-adapter-react-16'; import nock from 'nock'; import waitUntil from 'async-wait-until'; Enzyme.configure({ adapter: new Adapter() }); describe('test suite: Async component', () => { let resolve = false; beforeAll(() => { nock('http://localhost/api') .post('/value') .reply(200, () => { resolve = true; return {}; }); }); afterAll(() => { nock.cleanAll(); }); it('case: expect input & click operation correct', async () => { const wrapper = mount(<Interaction />); const input = wrapper.find('input').at(0); const button = wrapper.find('button').at(0); expect(input.exists()); expect(button.exists()); input.simulate('change', { target: { value: 'lucas' } }); expect(wrapper.state('value')).toBe('lucas'); button.simulate('click'); await waitUntil(() => resolve); expect(wrapper.state('value')).toBe('') }); }); 複製程式碼
整個測試用例完全pass,並且coverage為100%

最後
洋洋灑灑又是一個大長篇,有很多博主會將enzyme、nock、jest這類庫分開來講,但是在實際使用過程中,這幾個庫卻是密不可分的。
單元測試是前端工程化的一個不可避免的階段性工作,無論是開源工作還是業務工作,保證在每次迭代過程中程式碼的安全性於人於己都有很大的好處。
最後還是要說,撰寫測試用例的時候,一定要切記,單元測試並不是堆砌覆蓋率,而是保證每一個功能細節都被覆蓋到,不要捨本逐末了。