前端單元測試

福祿網路研發團隊發表於2021-11-02

前端單元測試

背景

  • 一直以來,單元測試並不是前端工程師必須具備的一項技能,在國內的開發環境下,普遍都要求快,因此往往會忽略了專案的程式碼質量,從而影響了專案的可維護性,可擴充套件性。隨著前端日趨工程化的發展,專案慢慢變得複雜,程式碼越來越追求高複用性,這更加促使我們提高程式碼質量,熟悉單元測試就顯得愈發重要了,它是保證我們程式碼高質量執行的一個關鍵。
  • 本文旨在探索單元測試的編寫思路,它對專案的影響,以及對日常開發習慣的一些思考。會涉及 jest 庫,詳細環境準備,及API使用規則可以參考 jest官網,這裡不做贅述。

概念

  • 黑盒測試:不管程式內部實現機制,只看最外層的輸入輸出是否符合預期。
  • E2E測試:(End To End)即端對端測試,屬於黑盒測試。 比如有一個加法的功能函式,有入參,有返回值,那麼通過編寫多個測試用例,自動去模擬使用者的輸入操作,來驗證這個功能函式的正確性,這種就叫E2E測試。
  • 白盒測試:通過程式的原始碼進行測試,而不是簡單的使用使用者介面觀察測試。本質上就是通過程式碼檢查的方式進行測試。
  • 單元測試:針對⼀些內部核心實現邏輯編寫測試程式碼,對程式中的最小可測試單元進行檢查和驗證。也可以叫做整合測試,即集合多個測試過的單元⼀起測試。它們都屬於白盒測試。

如何編寫單元測試

  • 第一步,先找到測試單元的輸入與輸出

    如何著手寫單元測試呢,首先要知道怎麼抓住程式單元的頭和尾,即測試臨界點。例如現在有個求和函式add,現在要給它寫單元測試,那麼它的關鍵節點是什麼呢?

    // add.js
    // 求和函式
    module.exports = {
      add(a, b) {
        return a + b;
      },
    };
    

    ​ 當我們呼叫add函式時,先會給它傳入兩個引數,函式執行完,會得到一個結果,所以我們可以以傳入引數作為起點(輸入),輸出值作為終點(輸出)去編寫測試用例。

    輸入

    將我們日常開發中的場景可以大致總結如下圖所示:

    常用案例

  • 第二步,測試模型,理清程式的輸入輸出後,再按如下三步驟編寫單元測試

    1. 準備測試資料(given)。
    2. 模擬測試動作(when)。
    3. 驗證結果(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模式則是將開發過程中的關注點剝離出來,一次只做一件事:

    1. 需求
    2. 實現
    3. 設計
  • TDD模式編寫測試用例,實現需求步驟

    1. 根據需求,假設需求功能已實現,先寫一個執行失敗的測試。(只關注需求)
    2. 編寫真實功能程式碼,讓測試程式碼執行成功。(只關注實現)
    3. 基於測試程式碼執行成功的基礎上,重構功能程式碼。(只關注設計)
  • 示例-火星探測器

    假想現在有這麼個需求:

    ​ 你在火星探索團隊中負責軟體開發。現在你要編寫控制程式,根據地球傳送的控制指令來控制火星車的行動。火星探測器會收到以下指令:

    1. 初始位置資訊:火星車的著落點(x, y)和火星車的朝向(N, S, E, W)。

    2. 轉向指令:火星車接受向左,向右指令,調轉車頭,朝向對應的方向(N, S, E, W)。

    3. 移動指令:火星車接受移動指令,前進或後退。

    因篇幅關係,只展示通過TDD模式實現初始化資訊位置和左轉向指令的功能,首先將需求進行拆解:

    1. 獲取初始化車的位置(座標postition 和方向direction)

    2. 實現左轉指令:

      • 輸入 input - turnLeft

      • 輸出 output, 傳入一個朝向,返回它左轉後的方向:

        • North --- West

        • West --- South

        • South --- East

        • East --- North

指南針1

火星探測器功能實現:

  1. 安裝環境(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"
  }
}

檔案目錄

  1. 測試一下環境是否搭建好了

    執行npm run test 將下面的測試程式碼跑起來,檢視控制檯資訊是否通過,通過則可以開始編寫測試用例。

    // car.spec.js
    test('jest', () => {
    	expect(1).toBe(1)
    })
    
  2. 按需求編寫對應的測試用例。

// 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'
        })
    })
})
測試01
  1. 根據測試用例實現功能,讓紅色錯誤資訊 變為綠色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
        }
    }
    
    測試用例2
  2. 獲取初始化資訊就算實現了,接下來按同樣的套路,去實現左轉指令

    // 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
            }
        }
        
    }
    
  3. 功能實現了,但是程式碼並不優雅,比如上面這些常量這樣寫很危險,一不小心就會報錯。還有 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

    lcov-report

總結

  • 單元測試的好處:
    1. 充分理解需求,拆解需求。
    2. 程式碼結構設計更簡練,易除錯,程式碼更健壯。
    3. 易重構。
    4. 除錯快。
    5. 實時文件,關鍵功能點,都有對應用例,哪裡不會看哪裡。
    6. 開源專案檢驗程式碼必備。
  • 透過單元測試,對目前專案及開發習慣的思考:
    1. 我們平時開發是否充分理解了需求。
    2. 是不是可以按照單元測試的規則去設計元件,減少層級巢狀深等引發的難維護,不易擴充套件問題。
    3. 針對複用性高的邏輯抽離,是不是可以適當的加上單元測試。
    4. 如何做到重構程式碼時,影響最小。
福祿·研發中心 福小凱

相關文章