在前公司寫給團隊成員參考的 Unit Test 小指南,僅管團隊已經分崩離析,也在此留下人生的足跡,紀念奮筆疾書的夜晚。

寫測試的理由

一開始寫測試,每個人都有的困惑肯定是「為什麼要寫測試?」,原本完成一項需求只要一個小時,但是加上寫測試可能變兩小時,工作量變兩倍,工時變長,人力成本也跟著增加,聽起來寫測試是一件完全不合理的事。

如果軟體跟罐頭一樣,經由生產線完工直接上架販售,不用維護也不用加新功能,在這個情況下,寫測試的確是不合理。

但是軟體會隨時間演進,你可能會需要修改其他人的程式碼,可能會重構三個月前自己寫下的程式碼,引入 Bug 是難以避免,沒有人能夠明白一個軟體產品的每一個角落,那些毀澀難懂的業務分支與邊際案例,一定會有當下沒有考慮到的情境,而寫了一段程式,造成其他人(或是三個月前的你)寫的程式碼直接斷成三截。

防範未然

在開發時順手寫測試,如果你的測試「正確」且「完整」的描述了當前程式的行為,等於是多了一個小幫手,在未來的每分每秒檢查是否有人無意間破壞了你寫的功能。提醒兇手,「你寫的程式碼會讓我寫的功能壞掉,他媽還不趕快修正!!」,同時也能防範上線後才發現該 Bug。

測試即文件

對於工程師,暸解一段函式、函式庫如何使用,最直觀的方式就是看「程式碼範例」,一份測試會描述程式在「每個情境下應該有的行為」,剛好可以作為完整的程式碼範例,

尤其是在 JavaScript 這樣可以寫出 Meta Programming 的語言裡,如果你不在測試裡描述 meta programming 的行為,往往難以理解該程式碼,因為 meta programming 所產生的類別與方法,都無法從程式碼中看見。

如果以上的說法太過高大上,而你又恰巧是個利己主義者,這裡還有兩個理由可以參考:

清晰地思緒

如果你跟隨著 TDD 的實踐,先寫測試再寫程式,寫測試時能夠先思考程式的「介面」,以及各個「邊際案例」,這能夠幫助釐清思緒。

如果直接實作,常常會因為實作難易,而忽略整體介面的一致性,以及忽略邊際案例的處理。

自動化驗證

如果不寫測試,驗證功能的時候就必須要手動操作,一直 console.log 不累嗎?讓測試幫你吧!

寫測試的流程

思考要怎麼寫一個測試的時候,會是一個循序漸進的流程,如下圖:

mental model for writing test

由上到下依序是

決定測試方法

依照專案的需求,決定應用哪一種測試方法。

E2E Test 以使用者的角度來檢測系統是否可用,能夠測試到最多程式碼,但也因為涵蓋很多程式碼,不僅很容易壞掉,測試失敗的時候也難以判斷是系統的哪一部分出錯。

單元測試以程式的角度出發,單獨測試特定的程式區塊,好處是測試失敗能夠很快定位錯誤,壞處是區塊與區塊間的相互運作可能會沒有被測到,這也是最容易撰寫的一種測試。

整合測試與單元測試相同,也是以程式的角度出發,目的是解決 Unit Test 無法測試到的程式碼區塊間的交互運作。

決定測試標的

決定好測試方法之後,在該方法下,要以什麼標的來發展接下來的測試案例。

E2E Test 的標的可以是使用者流程,以 Todo List 為例,可能的流程就有:

  • 使用者新增一筆 Todo
  • 使用者修改一筆 Todo
  • 使用者刪除一筆 Todo
  • 使用者清空 Todo

每個流程再接下去發展測試案例

整合測試能夠以「頁面」、「頁面中的每個區塊」作為標的,如果是後端 API Server 就是以每個 API Endpoint 作為標的

單元測試能夠以「Function」、「Class method」、「Module」或「Component」作為標的。

決定測試案例

根據標的物,需要決定需要哪些測試案例。

以單元測試為例,當前的測試標的是一個函式 sum

function sum (a, b) {
  return a + b
}

這時我們需要考慮 happy path 與 edge case

Happy Path 為

  • a 與 b 皆為數字的時候,會回傳兩者加總的整數

可能的 Edge Case 有

  • 當沒有傳入 a 或 b
  • 當 a 或 b 不為數字
  • …促繁不及備載

測試案例是寫測試最有歧異的部分,可以寫得多,也可以寫得少,需要考量的是,你所寫的這個 function 預期要提供哪些功能,例如上面的 sum 的程式碼,是一個不怎麼健壯,沒有錯誤處理的實作,但假如這就是你想要的實作,那在考慮測試案例時,也就不需要考慮參數不是數字的情況。

測試的排版與結構

要考慮的是如何組織你的測試,包括:

  • 測試的資料夾結構
  • 測試檔案內如何排版程式碼
  • 測試案例內的程式碼結構

我們會在下面的章節「測試的排版與結構」講到

測試的程式碼實作

需要考慮如何寫最少的測試達成最大的效益,以及在不同的情境,如何建立假資料(fixture)還有驗證(assertion)的技巧

測試的排版與結構

測試的「標的」、「案例」會影響我們如何組織專案內的測試,以下我們以 Jest 為測試框架,解釋組織測試的方法。

建立測試檔案

在 Jest 中,「測試檔案」放置的位置是看「標的」所在的檔案位置,「測試檔案」應放在該檔案資料夾的 __tests__ 下,並與「標的」的檔案名稱一致且後綴 .test.,後綴是為了讓我們能在搜尋檔案的時候,區分出一般檔案與測試檔案

例如:要測試 utils.js 下的 sum 函式,我們需要建立一個 utils.test.js 的檔案

└── /some-folder
    ├── /__tests__
    |   └── utils.test.js <--------- 測試檔案
    └── utils.js          <--------- sum 函式所在檔案

測試檔案內的排版

Jest 提供兩個 method describetest

describe 來描述標的是什麼,test 描寫測試案例的預期結果

describe("#sum", () => { // <------- 描寫標的
  test("return the sum of two numbers", () => { // <------- 描寫預期結果
    // ... 執行測試
  })
})

在簡單的測試案例時,上方的做法就足夠了,在有多個測試案例時,就可能會有多種輸入與預期結果有多種輸出的情況。

這時,有兩種方式可以考慮。

第一種,在 test() 中寫明輸入,如果不知道怎麼描寫輸入,建議可以多多使用 when 這個字,如下

describe("#sum", () => {
  // `when receive two number` 描寫輸入
  test("return the sum when receive two number", () => {

  })

  // `when receive other types instead of number` 描寫輸入
  test("throw TypeError when receive other types instead of number", () => {

  })
})

第二種,用 describe 將有一樣輸入的測試組合起來,在裡面用 test 列出所有預期的輸出。

describe("#sum", () => {
  describe("when receive two number", () => {
    test("return the sum", () => { })
    test("return number", () => { })
  })

  describe("when receive undefined", () => {
    test("throw TypeError", () => {

    })

    test("預期結果 1", () => {})
    test("預期結果 2", () => {})
    test("預期結果 3", () => {})
  })
})

一個測試案例的結構

test('我是一個測試案例', () => {
  // 這裡會發生什麼事呢???
})

一個測試程式碼應該包含四個階段(Four-Phase Test),Setup、Exercise、Verify、Teardown。

為什麼要遵守這四個階段呢?因為如果調換這四個階段的順序,測試很容易有 bug,寫測試時遵守這四個階段,也可以有一致的思考方式,加快寫測試的速度。

Phase 1 - Setup

建立該測試案例所需的環境

簡單的情況下,就只是設置幾個變數,有 Side Effect 的情況下,則需要建立 Mock, Stub

const name = 'Jack'
const response = {
  id: 5566,
  name
}
const createUserMock = jest
    // Mock - 建立假資料
  .spyOn(api, 'createUser')
  .mockImplementation(() => Promise.resolve(response))

Phase 2 - Exercise

執行測試

const user = await User.create(name)

Phase 3 - Verify

驗證執行的結果是否正確。

你將會使用五花八門的 assersion(Expect · Jest) 去完成這件事,遇到一個解決一個吧

expect(createUserMock).toHaveBeenCalledTimes(1)
expect(createUserMock).toHaveBeenCalledWith(name)
expect(user).toEqual(response)

Phase 4 - Teardown

如果有上述的階段有改變外部的環境,則需要將測試的世界回復原本的樣貌。

在 Setup 階段建立的 Mock,需要在這個階段將它取消 在執行階段產生的任何 side effect,也需要在這個階段將它取消

createUserMock.mockRestore()

結果

import * as api from '../apiWrapper'
import { create } from '../user'

test('User.create', async () => {
  // Setup 建立測試資料與 Mock Stub
  const name = 'Jack'
  const response = {
    id: 5566,
    name
  }
	const createUserMock = jest
		.spyOn(api, 'createUser')
		.mockImplementation(() => Promise.resolve(response))

  // Exercise 執行
  const user = await User.create(name)

  // Verify 驗證結果
	expect(createUserMock).toHaveBeenCalledTimes(1)
  expect(createUserMock).toHaveBeenCalledWith(name)
  expect(user).toEqual(response)

  // Teardown 消除 Mock,回到原始狀態
  createUserMock.mockRestore()
})

其他例子

test('#sum', () => {
  // Setup 建立測試資料
  const first = 3
  const second = 4

  // Exercise 執行待測目標
	const result = sum(first, second)

  // Verify 驗證結果
	expect(result).toBe(7)

  // Teardown
  // 沒有
})

合格的單元測試

Robert C. Martin,《無瑕的程式碼》一書提出單元測試應遵守 F.I.R.S.T 原則,我們可以依據該原則判斷單元測試是否合格。

Fast

測試就是要快

因為測試必須在每一次的改動都被執行,如果執行全部的測試需要耗時數分鐘,會影響開發的流暢度,也肯定很難落實在每次的程式碼更動執行測試。

所以怎麼樣的算是慢的單元測試? 大部分的測試都應該在 50ms 以下,如果超過,可能是因為沒有正確 mock 網路、IO 及時間相關的函式。

Independent

每一個測試案例都可以被獨立執行,且互不依賴

如果 A 測試一定要在 B 測試之後跑才會成功,先跑 B 再跑 A 就會失敗,表示違反了「獨立」原則。

有三個原因讓我們必須遵守此原則:

  1. 如果測試程式碼相互依賴,那些依賴的部分我們也必須要視為待測函式的輸入,而那些輸入,在程式實際運行的環境是不存在的。
  2. 當測試失敗時,無法很快的定位錯誤,因為測試之間相互關聯,測試 A 的失敗可能是 B 造成的。
  3. 測試程式無法平行執行。

通常會違反此原則有幾個可能:

  • 使用了全域變數。
  • 測試案例之間共享變數,而沒有在每個測試案例前重置該變數。
  • 沒有在每次測試案例執行之後將 mock 重置。

Repeatable

在任何時間、地點、網路狀況,測試結果都應該相同

如果測試時好時壞,基本上就是沒用的測試,因為它無法證明程式沒有出錯。

要讓測試反覆執行都能夠呈現相同的結果,我們要盡量消除外部變因,諸如:

  1. 不同的瀏覽器
  2. 執行測試時,不同機器使用不同的環境變數
  3. 程式中使用了時間相關功能,像是當前日期或是隨機函式
  4. 通過資料庫取得資料,而資料庫會被其他環境改變
  5. 通過網路取得資料

1, 2 點我們可以通過一致的測試環境設定解決,而 3, 4, 5 我們需要透過 Mock 來消除那些變因

Self-validating

測試執行後應能直接判斷「失敗」或「成功」其中一種情況

基本上我們不需要擔心此一原則,因為正確使用測試框架上是不會出現該情況的。

有可能出現該情況是,在測試中完全不使用 assertion,直接使用 console.log 顯示一些資料來判段測試是否成功。

應該沒有人會這麼做吧…

Thorough

測試必須盡可能地全面,有些人會理解為 Code coverage 越高越好,但覆蓋所有的使用案例才是應該考慮的事。

高效的單元測試

這個章節要談的是,當你決定好測試案例之後,要如何實作。

除了讓測試可以動之外,要努力追求下面兩點:

  1. 寫盡可能少的測試(不能賺錢的程式少寫)
  2. 無關的改動,不能讓測試壞掉(減少修測試所花的時間)

整個章節都是從上面兩點推論而來,我們必須要知道什麼該測,什麼不該測


在一個系統裡,各個單元會交互溝通,而組成完整的功能

system-interaction

如果我們聚焦在某一個單元上,會發現他與系統溝通的管道有三種:

single-unit

第一種方式 - 被其他單元呼叫

一個單元總會在某個時刻被呼叫,被呼叫時會接收參數,根據參數進行處理後,回傳對應的數值,或是直接拋出錯誤。

第三種方式 - Side Effect

Side Effect 指的是,除了兩個單元直接傳遞參數與回傳值之外,直接對外部的狀態造成影響,或是從外部的狀態取得資料。

像是「存取全域變數」、「檔案系統」、「資料庫」、「網路」、「DOM 操作」、「Browser Event」等都是常見的 Side Effect。

第二種方式 - 呼叫其他單元

與第一種方式相反,一個單元內部會去調用其他的單元取得結果,在調用的過程中也可能產生 side effect。

以上三種方式,針對他們對系統造成的影響,在測試四個階段中我們都有對應的行為應該要完成。

被其他單元呼叫

4-phase 行為 範例程式 為什麼?
Setup 準備輸入的參數 const a = 1; const b = 2  
Exercise 傳入參數    
Verify 驗證回傳值是否如同預期 expect(result).toBe(3)  
Teardown X    

Side Effect

大部分的 Side Effect 的操作都會被隱藏在其他單元之下,例如網路請求我們會透過 package Axios,File IO 透過 module fs,因此直接併入「呼叫其他單元」來解釋對應的行為

呼叫其他單元

需判斷被呼叫的單元屬於下列哪一種情形:

沒有任何 Side Effect

在 four-phase 中,不需要做任何事,因為被呼叫的單元應該自己測試自己的行為

且如果當前測試單元的測試成功,那也表示被呼叫單元的行為正常。

有 Side Effect,對外部狀態造成改變

change-state-four-phase

值得一提的是,為什麼在 Verify 階段不需要測試「被呼叫單元的回傳值」是因為回傳值已經被 mock 掉了 為什麼不需要測試「被呼叫單元所造成的 Side Effect 結果」,是因為被呼叫的單元應該要測試自己的行為,在這邊不需要重複測試。 如果被呼叫單元的測試能夠保證行為正確,我們就只需要確保呼叫時的輸入正確就行。

有 Side Effect,取得外部狀態

get-state-four-phase

Jest 中 Expect 的使用

這邊列出 90% 會用到的 expect 方法,一開始只需要知道下面這些就夠了。

大部分的 Primitive Type

測試的結果應該越精確越好,因此這些數值 Int, Boolean, Null, Undefined, String,用 toBe 就對了

expect(5566).toBe(5566);
expect(true).toBe(true);
expect(null).toBe(null);
expect(undefined).toBe(undefined);
expect("Jack is awesome!").toBe("Jack is awesome!");

Float

Float 用 toBeCloseTo 就對了

expect(0.2 + 0.1).toBe(0.3);           // 💀 Failed
expect(0.2 + 0.1).toBeCloseTo(0.3, 5); // 👍 Correct

Object

我們在比較 Object 的時候不使用 toBe,因為 toBe 等於 === 會比較 Reference,通常只希望知道 object 內部的 key and value 是否一樣。

預期 Object 中的每個 key 與 value 都要一樣的話,使用 toEqual()

expect({ a: "a", b: "b" }).toEqual({ a: "a", b: "b" }); // 👍
expect({ // nested 也 ok 👍
  a: "a",
  b: "b",
  nested: { foo: "bar" }
}).toEqual({
  a: "a",
  b: "b",
  nested: { foo: "bar" }
});

預期 Object 中的部分 key 與 value 一樣的話,使用 toMatchObject()

expect({ a: "a", b: "b" }).toMatchObject({ a: "a", b: "b" }); // 👍
expect({ a: "a", b: "b" }).toMatchObject({ a: "a" }); // 👍
expect({ a: "a", b: "b" }).toMatchObject({ xxx: "xxx" }); // 💀 noooooooooooooooo

Array

預期 item 完全一樣用 toEqual()

expect([1, 2, { a: "a" }]).toEqual([1, 2, { a: "a" }]);

預期包含特定 item

expect([1, 2, { a: "a" }]).toContainEqual({ a: "a" });

錯誤

test("Error", () => {
  // 👍 預期錯誤的型別
  expect(() => {
    throw new TypeError("Jack is Awesome");
  }).toThrow(TypeError);

  // 👍 預期錯誤訊息的內容
  expect(() => {
    throw new TypeError("Jack is Awesome");
  }).toThrow("Awesome");
});

Mock Method 與 Mock Module

因為 Jest 整份文件都在鼓勵 mock module,我決定來說一下 mock module 有哪些壞處,以及提供另外一種可行方法來取代 module mock。

壞處有三:

  1. 一但 mock 特定的 module,整份測試檔案都會生效,也不管某些測試案例不想要 mock 的行為
  2. manual mock 被整個系統共用,沒辦法針對特出的場景設計 mock (待確認)
  3. manual mock 要模擬真實的行為,通常會變成很複雜的實作,這種複雜度是我們希望在測試中避免的,畢竟我們不想再擔心程式之餘,還要擔心測試的 bug

我們舉 Jest 文件中的例子為例,原本使用 mock module 的方式來實作測試

// FileSummarizer.js
import fs from 'fs'

export function summarizeFilesInDirectorySync(directory) {
  return fs.readdirSync(directory).map(fileName => ({
    directory,
    fileName,
  }));
}

要將它改成不需要 mock module 的版本,只需要觀察 side effect 發生在哪一個方法,mock 該方法就行。

在上面的例子,我們需要 mock readdirSync

// __tests__/FileSummarizer.test.js
import fs from 'fs'
import FileSummarizer from '../FileSummarizer'

describe('FileSummarizer#summarizeFilesInDirectorySync', () => {
  const files = ['jack.js', 'is.js', 'awesome.avi']
  let spy
  beforeEach(() => {
    // setup - 作假
    spy = jest.spyOn(fs, 'readdirSync').mockReturnValue(files);
  });

  afterEach(() => {
    // teardown - 清空 side effect
    spy.mockRestore()
  })

  test('includes all files in the directory in the summary', () => {
    const directory = '/path/to'

    // exercise
    const fileSummary = FileSummarizer.summarizeFilesInDirectorySync(directory);

    // verify
    expect(fileSummary).toEqual([
      {
        directory,
        fileName: files[0]
      },
      {
        directory,
        fileName: files[1]
      },
      {
        directory,
        fileName: files[2]
      },
    ]);
  });
});

現在趕快對比一下 mock module 的實作,與上方 mock method 的實作,就會知道上方的實作非常容易,只需要找到 side effect 的發生處,然後 mock 回傳值,就這樣。

而且這個方式,能夠幾乎不用思考的移植到其他的情境。

Test Helper

有些人可能會覺得這樣 mock 的程式碼會需要到處都寫,但其實不會。

beforeEach(() => {
  spy = jest.spyOn(fs, 'readdirSync').mockReturnValue(files);
});

可以變成

// ../test/fsHelpers.js
import fs from 'fs';

export function spyFs(moethodName, returnedValue) {
  return jest.spyOn(fs, methodName).mockReturnValue(returnedValue);
}

// original test file
import { spyFs } from '../test/fsHelper'

beforeEach(() => {
  spy = spyFs('readdirSync', files)
});

使用 mock Module 的情境

滿足下列三個條件的情境,可能就需要 mock module 了

  1. 沒有使用 dependency injection
  2. 該 module 的不同的 methods 在同一個測試案例中被使用,且 module 內部會共享狀態
  3. module export default 一個 function,在測試被使用

上面的情境要怎麼 mock,就遇到了再說吧~~~~~~~。

結語

本篇文章講了 unit test 中抽象的概念,暸解這些概念可以在不同的系統之間舉一反三。 但如何應用在不同的 tech stack,這就需要在實踐中自己細細摸索,從經驗中找出自己的一套方法。

現在 React 是世界上最為流行的 framework 之一,如何在 React 中實作測試就顯得特別重要,沒有描述在使用 React 下如何測試元件是本篇文章的遺珠之憾,希望有時間能補上,讓本篇文章更完整。