聊聊前端開發的測試
最近在做 Coding 企業版 前端開發時花了很多時間寫測試,于是和大家分享一些前端開發中的測試概念與方法。
什么是寫測試代碼
我理解的寫測試其實是你寫一些代碼來驗證你所謂的可以交付的代碼是你所預期的設計,有一些朋友叫他 TDD 也就是測試驅動型的設計,其實到底是先寫代碼還是先寫測試,并不是最重要的,倒是能給你信心這個代碼是符合設計的更重要。
為什么要測試,前端需要測試么
這個問題不是這篇分享要和大家聊的,但是作為曾經也有這樣疑問的我還是簡單提一下。我們經常過于自信自己的代碼,因為編寫的時候已經做過 debug 調試,完事后覺得足夠了,或者期待下次重構再調整之。結果遇到 bug 無法最快時間確定問題,別人接手代碼也不知道這個模塊的設計意圖和使用方法,必須跳進去讀代碼,也不清楚改了一些內容后會不會影響這個模塊功能,又得耗時再次 debug 。在弱類型的語言尤其前端開發中尤為明顯。那種決定暫時棄之而不顧的的思想很可怕,因為我們沒有聽過過勒布朗法則:稍后等于永不。
聊聊測試的幾種類型 單元測試
從字面意思理解,寫一段代碼來測試一個單元。何為單元?其實和編程語言相關,他有可能是一個 function,一個 module 一個package 一個類,當然在 JavaScript 中也很有可能只是一個 object 。既然如此,那么測試這樣的一個小塊基本上就是比較孤立,單獨驗證這個小塊的邏輯,一個 function 的輸入輸出,一個算法的功能和復雜度等等。接下來舉幾個企業版前端開發中的實際案例。
我們使用 jest 作為測試框架(斷言庫)。jest會自動搜索所有文件目錄下的.spec.js結尾的文件,然后執行測試。斷言庫其實還有很多,他們都具備類似 describe , it , expect 些 api。對于一個沒有其他依賴的純函數,例如 redux 中同步 action 或 reducer。 我們要測的當然就是輸入用例然后對應輸出是否符合預期
it('should return showMore action', () => {
expect(showMore()).toEqual({
type: ACTION.DEMO_LIST_REMOVE_ITEM,
});
});
我們注意到這樣的一個 function 并沒有 I/O 和 UI 上的依賴,他更有利于做單元測試。其中的 it 接受一個 string 參數,描述一個小測試。另一個就是測試方法體函數,it 這種測試不能單獨使用,一般都包在一個 describe 方法下成為的方法組。那方法體里寫什么呢,其實我也可以寫成
if (showMore().type !== ACTION.DEMO_LIST_REMOVE_ITEM)
throw 'failed'
只要拋出異常那么框架就會認為這條測試跑不過。當然 expect 則 api 更加的漂亮,擁有 toEqual toBe、toMapSnap
shot 等判斷 api 確定兩個條件之間的關系.
對于純函數的測試并不難,難的還是如何把代碼寫的更可單元測試化,而不要有太多的依賴。
集成測試
事實上很多情況小塊代碼還是會有函數和 I/O 依賴,比如一些 code 依賴 Ajax 或者 localStorage 或者 IndexedDB ,這樣的代碼是不能被 united-test 的,于是我們需要 mock 相應依賴的接口拿到上下文測試我們的代碼,這樣的測試叫集成測試。我們項目中主要依賴了 js-dom 和異步的 action 。下面分別討論
涉及依賴的函數情況--(異步action)
事實上很多情況函數還是會有函數和I/O依賴,最典型的就是異步action等,他的I/O可能會依賴store.getState(),自身又會依賴異步中間鍵。這類使用原生js測試起來是比較困難的。我們思考我們測試目的,即當我們觸發了一個action后它經歷了一個圈異步最終store.getAction中這個action拿到的數據是否和我們預期一致。既然大家依賴redux中store的生命周期與store,于是我們需要兩個工具庫 redux-mock-store和nock ,于是測試就變成了這樣。
import configureMockStore from 'redux-mock-store';
import nock from 'nock';
import thunk from 'redux-thunk';
const mockStore = configureMockStore([thunk]);
// 配置mock的store,也讓他有我們相同的middleware
describe('get billings actions', () => {
afterEach(() => nock.cleanAll());// 每執行完一個測試后清空nock
it('create get all Billings action', () => {
const store = mockStore({
// 以我們約定的初始state創建store,控制I/O依賴
APP: { enterprise: { key : 'codingcorp' } }
});
const data = [
// 接口返回信息
{ ...
},
];
nock(API_HOST)// 攔截請求返回假定的response
.get(`/api/enterprise/codingcorp/billings`)
.reply(200, { code: 0, data })
return store.dispatch(actions.getAllBillings())
.then(() => {
expect(store.getActions()).toMatchSnapshot();
});
});
});
- 用 nock 來 mock 攔截 http 請求結果,并返回我們給定的 response。
- 用 redux-mock-store 來 mock store 的生命周期,需要預先把 middleware 配成和項目一致。
- desribe會包含一些生命周期的 api,比如全部測試開始做啥,單個測試結束做啥這類 api。這里每執行完一個測試就清空 nock。
- 用了 jest 中的 toMatchSnapshot api 判斷兩個條件一致與否。原先可能要寫成expect(store.getActions()).toEqual({data ...});這樣,你需要把 equal 里的東西都想具體描寫清楚,而 toMatchSnapshot 可在當前目錄下生成一個 snapshot 存放這個當前結果,寫測試時看一眼結果是預期的就可以 commit。如果改壞了函數就不匹配 snapshot 了。
涉及依賴的函數情況--(react component)
我們寫的很多component是extends component 的jsx,測試這類需要一個 mock component 的工具庫 Enzyme 。
it('should add key with never expire', () => {
...
掛載我們的dom
const wrapper = shallow(
<TwoFactorModal
verifyKey={verifyKeySpy}
onVerifySuccess={onVerifySuccessSpy}
/>
);
// wrapper的setstate方法
wrapper.setState({
name: 'test',
password: '123',
});
const name = 'new name';
const content = 'new content';
const expiration = '2016-01-01';
wrapper.find('.name').simulate('change', {}, name);
wrapper.find('.content').simulate('change', {}, content);
expect(wrapper.find('.permanentCheck').prop('checked')).toBe(true);
// 此處也可以使用toMatchSnapshot
// submit to add
wrapper.find('.submitBtn').simulate('click', e);
return promise.then(() => {
expect(onCheckSuccess).toBeCalledWith({
name,
password,
});
});
});
Enzyme 給我們提供了很多 react-dom 的事件操作與數據獲取。
這類 component 的測試一般分為
- Structural Testing 結構測試
主要關心一個界面是否有這些元素
例如我們有一個界面是
Screen Shot 2017-03-26 at 1.25.15 PM.png
結構化測試將包含:
- 一個title包含“登入到codingcorp.coding.net”
- 一個副標題包含“..”
- 兩個輸入框
- 一個提交按鈕
...
比較方便的實現就是利用 jest的snapshot 測試方法,先做一個預期生成snapshot,之后的版本與預期對比。
Interaction Testing 交互測試
比如上述案例觸發提交按鈕,他應該返回給我用戶名和密碼,并得到驗證結果
這類一般使用 Enzyme 比較方便
樣式測試
UI的樣式測試為了測試我們的樣式是否復合設計稿預期。同時通過樣式測試我們可以感受當我們 code 變化帶來的ui變化,以及他是否符合預期。
inline style
如果樣式是 inline style,這類測試其實直接使用 jest 的 Snapshot Testing 最方便,一般在組件庫中使用。
CSS
這部分其實屬于 E2E 測試中的部分,這里提前講,主要解決的問題是我們寫出來的ui是否符合設計稿的預期。我們使用 BackstopJS 他的原理是通過對頁面的viewports和 scenarios 等做配置,利用 web-driver 獲取圖片,與設計稿或者預期圖做 diff,產生報告。
{
// 需要測試的模塊元素定義
"viewports": [
{
"name": "password", //密碼框
"width": 320,
"height": 480
},
],
"scenarios": [
{
"label": "members",
"url": "/member/admin",
"selectors": [ // css選擇器
".member-selector"
],
"readyEvent": "gmapResponded",
"delay": 100,
"misMatchThreshold" : 1,
"onBeforeScript": "onBefore.js",
"onReadyScript": "onReady.js"
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"casperFlags": [],
"engine": "slimerjs",
"report": ["browser"],
"debug": false
}
最后會得出類似這樣的報告
E2E 測試
E2E 測試是在實際生產環境測試整個app,通常來說這部分工作會讓測試人工做,并在實體環境跑,就像用戶實際在操作一樣。靠人工做遇到項目邏輯比較復雜,則需要每一個版本都要測很多邏輯,擔心提交一個影響了其他部分。其實也有比較好的自動化跑腳本方案能幫助測試,我們使用 selenium-webdriver 工具配合async await進行自動化E2E測試。
const {prepareDriver, cleanupDriver} = require('../utils/browser-automation')
//...
describe('member', function () {
let driver
...
before(async () => {
driver = await prepareDriver()
})
after(() => cleanupDriver(driver))
it('should work', async function () {
const submitBtn = await driver.findElement(By.css('.submitBtn'))
await driver.get('http://localhost:4000')
await retry(async () => {
const displayElement = await driver.findElement(By.css('.display'))
const displayText = await displayElement.getText()
expect(displayText).to.equal('0')
})
await submitBtn.click()
})
selenium-webdriver 提供了很多瀏覽器的操作以及對元素對查找方法,以及元素內容的獲取方法,比如這里的 By.css 選擇器。
有時候用戶端的設備很不一致,需要在不同設備上的匹配,于是我們可以用 selenium-webdriver 搭配 sourcelab 的設備墻進行
測試覆蓋率與代碼變異測試
測試覆蓋率表達本次測試有有多少比例的語句,函數分支沒有被測到。當然絕對數字作為代碼質量依據并沒有什么意義,因為它是根據我們寫的測試來的。倒是學習為什么有些代碼沒有被覆蓋到,以及為什么有些代碼變了測試卻沒有失敗。很有意義。我們在jestconfig中配置完目標數據后,每次他會檢測我們的測試覆蓋率并給我們報告
Function Coverage 函數覆蓋
顧名思義,就是指這個函數是否被測試代碼調用了。以下面的代碼為例
,對函數exchange要做到覆蓋,只要一個測試——如expect(exchange(2, 2)) 就可以了。如果連函數覆蓋都達不到,那這個函數是否真的需要。
let z = 0
if (x>0 && y>0) {
z=x
}
return z
}
Line Coverage 語句覆蓋
還是前面那個 exchange 例子,他檢測的是某一行代碼是否被測試覆蓋了,同樣 選擇用例2,2也能覆蓋它,但是如果變成 2, -1 就不行了。通常這種情況是由于一些分支語句導致的,因為相應的問題就是“那行代碼(以及它所對應的分支)需要嗎?
Decision Coverage 決策覆蓋
它是指每一個邏輯分支是否被測試覆蓋了,有一個if的真和假一般就要兩組用例,至少測一組 true 一組 false
Condifiton Coverage 條件覆蓋
它是指分支中的每個條件是否被測試覆蓋了,像前面那個exchange例子,要達到全部條件覆蓋,測試用例就需要四個,即 x 和 y 四種情況,如果測不到就要思考是否不需要某個分支呢
代碼變異測試
說到這里重新提一下 jest 的 toMatchSnapshot 實踐,他對期望的表達并不是寫一個期望值和實際做匹配,而是生成一個快照讓我們之后的每次變異代碼和它匹配, jest--watch 的實時測試變動的代碼更方便做這個事。
這里所謂的變異是指修改一處代碼來改變代碼的行為,檢查測試是否因為這個代碼的變異而失敗,如果有失敗則說明這個變異被消滅,此時的測試本身行為是符合預期。不然變異存活則測試不到位。
平時用到比較多的變異方法是:
條件邊界變異、反向條件變異、數學運算變異、增量運算變異、負值翻轉變異等
小結 養成寫測試的好習慣能避免很多問題,極大的提升效率,避免重復 debug。在前端開發中由于語言本身對寫法限制比較弱,測試保障非常重要,既讓自己對代碼有信心也讓別人更容易理解你設計的每一個模塊用意。在寫代碼的時候就要從可測試如何測試的角度思考,盡量每一行代碼都是有用且符合預期的。
來自:http://www.iteye.com/news/32271