下面的文章會預設讀者瞭解 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.jsimport 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);
});
});
複製程式碼
執行npm run test
,可以得到下面的結果:
可以看到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這類庫分開來講,但是在實際使用過程中,這幾個庫卻是密不可分的。
單元測試是前端工程化的一個不可避免的階段性工作,無論是開源工作還是業務工作,保證在每次迭代過程中程式碼的安全性於人於己都有很大的好處。
最後還是要說,撰寫測試用例的時候,一定要切記,單元測試並不是堆砌覆蓋率,而是保證每一個功能細節都被覆蓋到,不要捨本逐末了。