前端單元測試
背景
- 一直以來,單元測試並不是前端工程師必須具備的一項技能,在國內的開發環境下,普遍都要求快,因此往往會忽略了專案的程式碼質量,從而影響了專案的可維護性,可擴充套件性。隨著前端日趨工程化的發展,專案慢慢變得複雜,程式碼越來越追求高複用性,這更加促使我們提高程式碼質量,熟悉單元測試就顯得愈發重要了,它是保證我們程式碼高質量執行的一個關鍵。
- 本文旨在探索單元測試的編寫思路,它對專案的影響,以及對日常開發習慣的一些思考。會涉及 jest 庫,詳細環境準備,及API使用規則可以參考 jest官網,這裡不做贅述。
概念
- 黑盒測試:不管程式內部實現機制,只看最外層的輸入輸出是否符合預期。
- E2E測試:(End To End)即端對端測試,屬於黑盒測試。 比如有一個加法的功能函式,有入參,有返回值,那麼通過編寫多個測試用例,自動去模擬使用者的輸入操作,來驗證這個功能函式的正確性,這種就叫E2E測試。
- 白盒測試:通過程式的原始碼進行測試,而不是簡單的使用使用者介面觀察測試。本質上就是通過程式碼檢查的方式進行測試。
- 單元測試:針對⼀些內部核心實現邏輯編寫測試程式碼,對程式中的最小可測試單元進行檢查和驗證。也可以叫做整合測試,即集合多個測試過的單元⼀起測試。它們都屬於白盒測試。
如何編寫單元測試
-
第一步,先找到測試單元的輸入與輸出
如何著手寫單元測試呢,首先要知道怎麼抓住程式單元的頭和尾,即測試臨界點。例如現在有個求和函式add,現在要給它寫單元測試,那麼它的關鍵節點是什麼呢?
// add.js // 求和函式 module.exports = { add(a, b) { return a + b; }, };
當我們呼叫add函式時,先會給它傳入兩個引數,函式執行完,會得到一個結果,所以我們可以以傳入引數作為起點(輸入),輸出值作為終點(輸出)去編寫測試用例。
將我們日常開發中的場景可以大致總結如下圖所示:
-
第二步,測試模型,理清程式的輸入輸出後,再按如下三步驟編寫單元測試
- 準備測試資料(given)。
- 模擬測試動作(when)。
- 驗證結果(then)。
還是以求和函式 add 為例子編寫測試套件:
// add.spec.js const { add } = require("./add"); it("測試add求和函式", () => { // given -> 準備測試資料 const a = 1; const b = 1; // when -> 模擬測試動作 const result = add(a, b); // then -> 驗證結果 expect(result).toBe(2); });
-
小結
以上的操作,實際上可以想象為把我們要測試的函式或元件當作成一個冰箱,往冰箱裡放一瓶水,過一段時間,會得到一瓶冰水。那麼往冰箱放一瓶水是輸入,拿出一瓶冰水是輸出。我們的程式不管多複雜,也可以按上面這樣先找到臨界點。這樣我們就知道從哪裡開始測試,到哪裡結束,從而按照測試步驟,模擬程式,論證得到的結果。
TDD模式
上面我們已經瞭解瞭如何編寫單元測試用例,那我們如何利用單元測試幫助我們合理產出呢?就像上面 add函式的例子,我們是先實現了功能,再去測試功能的。如果單元測試僅僅是用來這樣去產出的話,那也未免太雞肋了。回想一下,我們目前的常規開發模式是拿到需求,實現需求,再去測試我們程式是否達到了交付要求。而TDD模式,則完全顛覆了這個過程,它是先寫單元測試用例,通過單元測試用例來確定編寫什麼樣的程式碼,實現什麼樣的功能,即測試驅動開發(Test Driven Development)。
-
核心思想
開發功能程式碼前,先編寫測試程式碼。
-
本質
我們常用的開發模式是先實現功能,再測試。在實現過程中,我們可能需要考慮需求是什麼,如何去實現它,程式碼該如何設計,擴充套件性更好,更易維護等等問題,每次當我們實現某個功能時,都要考慮這些問題,有時會感覺不知道怎麼寫才合適。而TDD模式則是將開發過程中的關注點剝離出來,一次只做一件事:
- 需求
- 實現
- 設計
-
TDD模式編寫測試用例,實現需求步驟
- 根據需求,假設需求功能已實現,先寫一個執行失敗的測試。(只關注需求)
- 編寫真實功能程式碼,讓測試程式碼執行成功。(只關注實現)
- 基於測試程式碼執行成功的基礎上,重構功能程式碼。(只關注設計)
-
示例-火星探測器
假想現在有這麼個需求:
你在火星探索團隊中負責軟體開發。現在你要編寫控制程式,根據地球傳送的控制指令來控制火星車的行動。火星探測器會收到以下指令:
-
初始位置資訊:火星車的著落點(x, y)和火星車的朝向(N, S, E, W)。
-
轉向指令:火星車接受向左,向右指令,調轉車頭,朝向對應的方向(N, S, E, W)。
-
移動指令:火星車接受移動指令,前進或後退。
因篇幅關係,只展示通過TDD模式實現初始化資訊位置和左轉向指令的功能,首先將需求進行拆解:
-
獲取初始化車的位置(座標postition 和方向direction)
-
實現左轉指令:
-
輸入 input - turnLeft
-
輸出 output, 傳入一個朝向,返回它左轉後的方向:
-
North --- West
-
West --- South
-
South --- East
-
East --- North
-
-
-
火星探測器功能實現:
- 安裝環境(package.json及檔案目錄):
{
"name": "car",
"version": "1.0.0",
"description": "",
"main": "car.js",
"scripts": {
"test": "jest --watchAll"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/jest": "^27.0.1",
"jest": "^27.1.0"
}
}
-
測試一下環境是否搭建好了
執行npm run test 將下面的測試程式碼跑起來,檢視控制檯資訊是否通過,通過則可以開始編寫測試用例。
// car.spec.js test('jest', () => { expect(1).toBe(1) })
-
按需求編寫對應的測試用例。
// car.spec.js
// 假設獲取火星車初始著陸座標和朝向功能已實現,直接編寫測試用例,假設初始座標為(0,0),朝向north。
// Position是一個類,它用來設定火星車的座標。
// Car是一個類,他含有需求要求的兩個指令功能:獲取初始位置,發出左轉指令讓火星車正確轉向。
// 此時的 car.js 和 position.js 檔案還什麼都沒有寫,實際功能並未實現,此時控制檯顯示紅色錯誤資訊,測試未通過。
const Position = require('../position')
const Car = require('../car')
describe('car', () => {
it('init position and directon', () => {
const position = new Position(0, 0)
const car = new Car(position, 'north')
expect(car.getState()).toEqual({
position: {
x: 0,
y: 0
},
direction: 'north'
})
})
})
-
根據測試用例實現功能,讓紅色錯誤資訊 變為綠色pass。
// car.js module.exports = class Car{ constructor(position, direction) { this.position = position this.direction = direction } getState() { return { position: this.position, direction: this.direction } } }
// position.js module.exports = class Position{ constructor(x, y) { this.x = x this.y = y } }
-
獲取初始化資訊就算實現了,接下來按同樣的套路,去實現左轉指令
// car.spec.js const Position = require('../position') const Car = require('../car') describe('car', () => { it('init position and directon', () => { const position = new Position(0, 0) const car = new Car(position, "north") expect(car.getState()).toEqual({ position: { x: 0, y: 0 }, direction: "north" }) }) describe('turnLeft', () => { it('North --- West', () => { const car = new Car(new Position(0, 0), "north") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "west", }) }) it('West --- South', () => { const car = new Car(new Position(0, 0), "west") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "south", }) }) it('South --- East', () => { const car = new Car(new Position(0, 0), "south") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "east", }) }) it('East --- North', () => { const car = new Car(new Position(0, 0), "east") car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: "north", }) }) }) })
// car.js module.exports = class Car{ constructor(position, direction) { this.position = position this.direction = direction } getState() { return { position: this.position, direction: this.direction } } // 左轉 turnLeft() { if(this.direction === "north"){ this.direction = "west" return } if(this.direction === "west"){ this.direction = "south" return } if(this.direction === "south"){ this.direction = "east" return } if(this.direction === "east"){ this.direction = "north" return } } }
-
功能實現了,但是程式碼並不優雅,比如上面這些常量這樣寫很危險,一不小心就會報錯。還有 turnLeft 函式,裡面的流程完全一樣,可以進行公共邏輯抽離。因為我們現在有單元測試了,所以我們可以放心大膽的對功能進行改造,單元測試會實時的告訴我們程式哪裡會有問題,我們不需要像以前那樣調整一下程式碼,就去console.log一下,或者在頁面進行除錯,現在只需要保證將控制檯輸出的error調整為 pass 狀態即可,改造後的程式碼如下:
// ../constant/direction // 常量提取 module.exports={ N: "north", W: "west", S: "south", E: "east", }
// ../constant/directionMap const Direction = require('./direction') const map = { [Direction.N]: { left: Direction.W }, [Direction.W]: { left: Direction.S }, [Direction.S]: { left: Direction.E }, [Direction.E]: { left: Direction.N } } // 流程抽離,當我們傳入一個方向時,返回他左轉後的方向 module.exports = { turnLeft: direction => map[direction].left }
// car.spec.js const Direction = require('../constant/direction') const Position = require('../position') const Car = require('../car') describe('car', () => { it('init position and directon', () => { const position = new Position(0, 0) const car = new Car(position, Direction.N) expect(car.getState()).toEqual({ position: { x: 0, y: 0 }, direction: Direction.N }) }) describe('turnLeft', () => { it('North --- West', () => { const car = new Car(new Position(0, 0), Direction.N) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.W, }) }) it('West --- South', () => { const car = new Car(new Position(0, 0), Direction.W) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.S, }) }) it('South --- East', () => { const car = new Car(new Position(0, 0), Direction.S) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.E, }) }) it('East --- North', () => { const car = new Car(new Position(0, 0), Direction.E) car.turnLeft() expect(car.getState()).toEqual({ position: { x: 0, y: 0, }, direction: Direction.N, }) }) }) })
// car.js const Direction = require('./constant/direction') const { turnLeft } = require('./constant/directionMap') module.exports = class Car{ constructor(position, direction) { this.position = position this.direction = direction } getState() { return { position: this.position, direction: this.direction } } turnLeft() { this.direction = turnLeft(this.direction) } }
測試覆蓋率
-
如果專案已經寫完了,如何檢視專案測試覆蓋率,根據測試覆蓋率針對性調整程式碼?修改package.json檔案中的 scripts執行指令碼,執行npm run test,根目錄下會生成一個coverage資料夾,找到該資料夾下 lcov-report檔案中的index.html,在瀏覽器中開啟,可以檢視各個檔案的測試用例覆蓋率。
package.json
"scripts": { "test": "jest --coverage" }
coverage/lcov-report/index.html
總結
- 單元測試的好處:
- 充分理解需求,拆解需求。
- 程式碼結構設計更簡練,易除錯,程式碼更健壯。
- 易重構。
- 除錯快。
- 實時文件,關鍵功能點,都有對應用例,哪裡不會看哪裡。
- 開源專案檢驗程式碼必備。
- 透過單元測試,對目前專案及開發習慣的思考:
- 我們平時開發是否充分理解了需求。
- 是不是可以按照單元測試的規則去設計元件,減少層級巢狀深等引發的難維護,不易擴充套件問題。
- 針對複用性高的邏輯抽離,是不是可以適當的加上單元測試。
- 如何做到重構程式碼時,影響最小。