本文首發於 Vue 應用單元測試的策略與實踐 02 - 單元測試基礎 | 呂立青的部落格
歡迎關注知乎專欄 —— 前端的逆襲(凡可 JavaScript,終將 JavaScript。)
本文的目標
- 在 TDD 做完 Tasking 列完例項化資料之後,完全沒有UT基礎不知道該怎麼寫單元測試?
// Given
一個完全沒有UT基礎的新人?
// When
當他?閱讀和練習本文的Jest的部分
// Then
他能夠把Given/When/Then的套路學會
他能夠學會Jest的基本用法,包括測試suite和斷言等語法
他能夠學會Jest中測試非同步的幾種方式
複製程式碼
單元測試基礎
在上一篇文章當中我們介紹了單元測試的意義,以及為何選擇 Facebook 的 Jest 作為我們的測試框架。現在就讓我們一起來學習如何編寫最基礎的單元測試。
如果你已經有了使用 Jest 編寫單元測試的經驗,可以選擇直接跳到第二段。
第一個 Jest 例項
首先建立 jest-demo
專案並安裝 jest
作為專案 devDependencies
依賴:
mkdir jest-demo && cd $_
yarn init -y #--yes
yarn add jest -D #--dev
複製程式碼
然後建立一個 math.js
檔案,輸入一個我們稍後測試的 sum
函式:
const sum = (a, b) => a + b
module.exports = { sum }
複製程式碼
接下來,讓我們寫第一個測試。在同一個資料夾中建立一個 math.test.js
檔案,在這裡我們將使用 Jest 來測試 math.js
中定義的函式:
const { sum } = require('./math')
describe('Math module', () => {
it("should return sum result when one number plus another number", () => {
// Given
const number = 1
const anotherNumber = 2
// When
const result = sum(number, anotherNumber)
// Then
expect(result).toBe(2)
})
})
複製程式碼
然後執行 yarn test
(新增 NPM Script)你就可以看到相應的結果。
Given/When/Then 的套路
麻雀雖小五臟俱全,在上面的例子當中,我們可以看到很多的測試元素,下面將會一一介紹:
首先我們看到的是一個由 it
包裹的測試主體最小單元,採用了Given When Then的經典格式,我們常常稱之為測試三部曲,也可以解釋為 3A 即:
GWT | 3A | 說明 |
---|---|---|
Given | Arrange | 準備測試測試資料,有時可以抽取到 beforeEach |
When | Act | 採取行動,一般來說就是呼叫相應的模組執行對應的函式或方法 |
Then | Assert | 斷言,這時需要藉助的就是Matchers的能力,Jest還可以擴充套件自己的Matcher |
在 expect
後面的 toBe
稱之為 Matcher,是斷言時的判斷語句以驗證正確性 ✅,在後面的文章中我們還會接觸更多 Matchers,甚至可以擴充套件一些特別定製的 Matchers。
expect(1+1).toBe(2)
expect(1+1).not.toBe(3)
複製程式碼
修改斷言的結果,就可以看到成功後的結果了:
模組間依賴 Fake/Stub/Mock/Spy
如同人類世界中的羈絆,軟體模組之間必然也免不了依賴。Martin Fowler 在 UnitTest 這篇文章當中將單元測試作了一個重要的區分,即你所測試的單位應該是社交型(Social Tests)還是獨立型(Solitary Tests)? 想象一下你正在測試一個 Order
Class 的 price()
方法,而 price()
方法需要在 Product
和 Customer
Class 中呼叫一些函式。如果你希望單元測試所測試的 Order
模組是獨立的,那麼你就不想直接使用真正的 Product
或 Customer
Class,因為 Customer
Class 的錯誤會直接導致 Order
Class 的單元測試失敗。相反,你可能會使用一個替身作為依賴的物件,也就是我們接下來會提到的 Fake/Stub/Mock/Spy。
現實世界裡,我們在寫程式碼和單元測試時,常常遇到的一些需要替身的物件包括:
- Database 資料庫
- Network requests 網路請求
- access to Files 存取檔案
- any External system 任何外部系統
其實在 Jest 當中,Fake/Stub/Mock/Spy 這些概念或許會有所混淆,而這跟 JavaScript 語言本身的特點有一定關係,但是我覺得 Jest 通過統一的 fn()
方法把問題解決得還比較恰當,讓我們來一塊兒看看例項?:
Mock 用於替代整個模組
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
return jest.fn().mockImplementation(() => {
return {playSoundFile: mockPlaySoundFile};
});
});
複製程式碼
我們可以看到 jest.mock()
方法中的第二個引數是一個函式,那麼我們就可以完全接管整個 ./sound-player
JavaScript 模組,比如說這裡的 playSoundFile
本來應該是從 ./sound-player
這個檔案當中 export
出來的,而被 Mock 之後我們的測試就可以使用 Mock 所返回的資料或方法,從而保證模組所返回的內容是我們所期望的。但這時需要注意的是,該模板的所有功能都已經被 Mock 掉,而不會再從原模組當中返回,所以我們就需要重新實現該模組中的所有功能。可別一不小心就成了張藝謀導演《影》片中的影子,被完全“取而代之”,連夫人也被 Mock 所吸引。
Stub 用於模擬特定行為
const mockFn = jest.fn();
mockFn();
expect(mockFn).toHaveBeenCalled();
// With a mock implementation:
const returnsTrue = jest.fn(() => true);
console.log(returnsTrue()); // true;
複製程式碼
這裡的特定行為也可以是沒有行為,jest.fn()
代表著我就是一個 Stub(樁),“你來我就在這裡,你走我也依然在這裡,風雨無阻”。不需要什麼輸入輸出,只要能在測試的時候驗證到 Stub 被呼叫過就行,也就能夠斷言到某處程式碼被執行,從而確定程式碼被測試所覆蓋。而另一種特定行為就是返回特定的資料,即 Stub 也可以根據輸入模擬返回一種輸出,作為某些模組的替身幫它演戲,比如“小鮮肉們”遇到要跳車啦、要卿卿我我(誤)的時候就要找替身,“一二三四五六七八”連臺詞都不用背還需要配音。
Spy 用於監聽模組行為
Spy packages without affecting the functions code
const video = require('./video');
it('plays video', () => {
const spy = jest.spyOn(video, 'play');
const isPlaying = video.play();
expect(spy).toHaveBeenCalled();
expect(isPlaying).toBe(true);
})
複製程式碼
Spy 並不會影響到原有模組的功能程式碼,而只是充當一個監護人的作用,“你可以繼續我型我秀上課講小話,但是老師會偷偷告訴你媽媽,看你放學後老媽不打斷你的腿”。比如說上文中的 video
模組中的 play()
方法已經被 spy
過,那麼之後 play()
方法只要被呼叫過,我們就能判斷其是否執行,甚至執行的次數。
如何 Mock 全域性的方法?
把全域性的資料 Mock 掉很簡單,只需要像 window.document.title = undefined
這樣簡單 Fake 賦值就很完美。而像 matchMedia
這樣的方法在 jsdom 裡面並沒有被實現,這時候我們當然就需要去把它 Mock 掉,簡單把要用到的一些物件屬性賦值就好,總之不至於在執行時報錯。
window.matchMedia = jest.fn().mockImplementation(query => {
return {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
};
});
複製程式碼
程式碼模組的易測性
從上文的一些例子當中,我們也可以看到,不管是 Fake/Stub/Mock/Spy 最最重要的一個原則就是「簡單」,因為我們是在寫測試程式碼,而所依賴的模組就應該以最簡單的形態展現出來,絕不要給 jest.fn()
編寫過於哪怕一點點複雜的邏輯。如果這個模組有多種表現形態,那就把它分種測試單元進行多次 Mock,每個 it()
單元測試一定是針對於單個功能點進行測試的。
保持單元測試獨立性的同時,也是在促使你去思考什麼樣的模組才是符合「職責單一原則」的。單元測試站在使用者的角度來使用該模組,而程式碼的易測性也就代表著程式碼的可維護性。
如何測試非同步程式碼?
非同步是 JavaScript 中繞不開的永恆話題,多虧了 ES6+ 高階語法所提供的多種優雅的非同步程式碼方式,讓我們寫測試程式碼的方式也多了好多種。(逃
讓我們先來看一下什麼是非同步請求,這裡有一個通過 Chrome API 獲取當前位置的例項,可想而知 Chrome 要根據 GPS 訊號才能算出當前的經緯度,相當於從衛星?來回走了一遭,怎麼不會非同步(代表有延時,延遲返回)呢?
navigator.geolocation.getCurrentPostion() # chrome API 非同步獲取當前位置
複製程式碼
Callback 回撥函式
it('the data is peanut butter', done => {
function callback(data) {
expect(data).toBe('peanut butter');
done();
}
fetchData(callback);
});
複製程式碼
這是最最普通的方式,也是各大框架都支援的一種寫法, done()
作為非同步程式碼結束的結束標誌,從而讓測試框架“知道”在結束時進行斷言。但這種方式侵入性比較強,對測試語句不友好且違背了 Given/When/Then 的三段式套路,就像回撥地獄一樣的道理,如果讓 done()
充斥著測試那麼程式碼也就變得混亂。
Promise 讓愛 then()
到底
it('the data is peanut butter', () => {
expect.assertions(1);
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
複製程式碼
expect(Promise.resolve('lemon')).resolves.toBe('lemon')
expect(Promise.reject(new Error('octopus'))).rejects.toThrow('octopus')
複製程式碼
其實這種方式也好不到哪去,無非就是把 done()
方式換成了 then()
又一次充斥在整個 expect 當中,混亂了 When 和 Then 兩種本該分開的時刻。但也有一個不錯的點,可以通過 Promise 的 .resolve()
和 .reject()
方法使測試分別驗證正常或異常的情況。
Async/Await 讓非同步變得同步
test('the data is peanut butter', async () => {
expect.assertions(1);
const data = await fetchData();
expect(data).toBe('peanut butter');
});
複製程式碼
Async/Await 語法糖在業務程式碼當中就特別好使了,好處不多說直接看得見:原本需要 done()
或 then()
的地方都不再混亂,又一次迴歸到了正常的 Given/When/Then 三段式套路,讓測試程式碼變得非常清晰易讀。唯一需要注意的是, 額外的expect.assertions(number)
其實是驗證在測試期間所呼叫的斷言數量,這在測試多層非同步程式碼時很有用,以確保實際呼叫回撥中的斷言次數。
意猶未盡嗎?更加Jest相關的內容可以檢視這篇文章 Testing JavaScript with Jest,與此同時具體的 API 可以參考官方文件。
未完待續……
## 單元測試基礎
- [x] ### 單元測試與自動化的意義
- [x] ### 為什麼選擇 Jest
- [x] ### Jest 的基本用法
- [x] ### 該如何測試非同步程式碼?
## Vue 單元測試
- [ ] ### Vue 元件的渲染方式
- [ ] ### Wrapper
find()
方法與選擇器 - [ ] ### UI 元件互動行為的測試
## Vuex 單元測試
- [ ] ### CQRS 與
Redux-like
架構 - [ ] ### 如何對 Vuex 進行單元測試
- [ ] ### Vue元件和Vuex store的互動
## Vue應用測試策略
- [ ] ### 單元測試的特點及其位置
- [ ] ### 單元測試的關注點
- [ ] ### 應用測試的測試策略