暸解單元測試
在前公司寫給團隊成員參考的 Unit Test 小指南,僅管團隊已經分崩離析,也在此留下人生的足跡,紀念奮筆疾書的夜晚。
寫測試的理由
一開始寫測試,每個人都有的困惑肯定是「為什麼要寫測試?」,原本完成一項需求只要一個小時,但是加上寫測試可能變兩小時,工作量變兩倍,工時變長,人力成本也跟著增加,聽起來寫測試是一件完全不合理的事。
如果軟體跟罐頭一樣,經由生產線完工直接上架販售,不用維護也不用加新功能,在這個情況下,寫測試的確是不合理。
但是軟體會隨時間演進,你可能會需要修改其他人的程式碼,可能會重構三個月前自己寫下的程式碼,引入 Bug 是難以避免,沒有人能夠明白一個軟體產品的每一個角落,那些毀澀難懂的業務分支與邊際案例,一定會有當下沒有考慮到的情境,而寫了一段程式,造成其他人(或是三個月前的你)寫的程式碼直接斷成三截。
防範未然
在開發時順手寫測試,如果你的測試「正確」且「完整」的描述了當前程式的行為,等於是多了一個小幫手,在未來的每分每秒檢查是否有人無意間破壞了你寫的功能。提醒兇手,「你寫的程式碼會讓我寫的功能壞掉,他媽還不趕快修正!!」,同時也能防範上線後才發現該 Bug。
測試即文件
對於工程師,暸解一段函式、函式庫如何使用,最直觀的方式就是看「程式碼範例」,一份測試會描述程式在「每個情境下應該有的行為」,剛好可以作為完整的程式碼範例,
尤其是在 JavaScript 這樣可以寫出 Meta Programming 的語言裡,如果你不在測試裡描述 meta programming 的行為,往往難以理解該程式碼,因為 meta programming 所產生的類別與方法,都無法從程式碼中看見。
如果以上的說法太過高大上,而你又恰巧是個利己主義者,這裡還有兩個理由可以參考:
清晰地思緒
如果你跟隨著 TDD 的實踐,先寫測試再寫程式,寫測試時能夠先思考程式的「介面」,以及各個「邊際案例」,這能夠幫助釐清思緒。
如果直接實作,常常會因為實作難易,而忽略整體介面的一致性,以及忽略邊際案例的處理。
自動化驗證
如果不寫測試,驗證功能的時候就必須要手動操作,一直 console.log 不累嗎?讓測試幫你吧!
寫測試的流程
思考要怎麼寫一個測試的時候,會是一個循序漸進的流程,如下圖:
由上到下依序是
決定測試方法
依照專案的需求,決定應用哪一種測試方法。
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 describe
與 test
。
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 就會失敗,表示違反了「獨立」原則。
有三個原因讓我們必須遵守此原則:
- 如果測試程式碼相互依賴,那些依賴的部分我們也必須要視為待測函式的輸入,而那些輸入,在程式實際運行的環境是不存在的。
- 當測試失敗時,無法很快的定位錯誤,因為測試之間相互關聯,測試 A 的失敗可能是 B 造成的。
- 測試程式無法平行執行。
通常會違反此原則有幾個可能:
- 使用了全域變數。
- 測試案例之間共享變數,而沒有在每個測試案例前重置該變數。
- 沒有在每次測試案例執行之後將 mock 重置。
Repeatable
在任何時間、地點、網路狀況,測試結果都應該相同
如果測試時好時壞,基本上就是沒用的測試,因為它無法證明程式沒有出錯。
要讓測試反覆執行都能夠呈現相同的結果,我們要盡量消除外部變因,諸如:
- 不同的瀏覽器
- 執行測試時,不同機器使用不同的環境變數
- 程式中使用了時間相關功能,像是當前日期或是隨機函式
- 通過資料庫取得資料,而資料庫會被其他環境改變
- 通過網路取得資料
1, 2 點我們可以通過一致的測試環境設定解決,而 3, 4, 5 我們需要透過 Mock 來消除那些變因
Self-validating
測試執行後應能直接判斷「失敗」或「成功」其中一種情況
基本上我們不需要擔心此一原則,因為正確使用測試框架上是不會出現該情況的。
有可能出現該情況是,在測試中完全不使用 assertion,直接使用 console.log 顯示一些資料來判段測試是否成功。
應該沒有人會這麼做吧…
Thorough
測試必須盡可能地全面,有些人會理解為 Code coverage 越高越好,但覆蓋所有的使用案例才是應該考慮的事。
高效的單元測試
這個章節要談的是,當你決定好測試案例之後,要如何實作。
除了讓測試可以動之外,要努力追求下面兩點:
- 寫盡可能少的測試(不能賺錢的程式少寫)
- 無關的改動,不能讓測試壞掉(減少修測試所花的時間)
整個章節都是從上面兩點推論而來,我們必須要知道什麼該測,什麼不該測
在一個系統裡,各個單元會交互溝通,而組成完整的功能
如果我們聚焦在某一個單元上,會發現他與系統溝通的管道有三種:
第一種方式 - 被其他單元呼叫
一個單元總會在某個時刻被呼叫,被呼叫時會接收參數,根據參數進行處理後,回傳對應的數值,或是直接拋出錯誤。
第三種方式 - 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,對外部狀態造成改變
值得一提的是,為什麼在 Verify 階段不需要測試「被呼叫單元的回傳值」是因為回傳值已經被 mock 掉了 為什麼不需要測試「被呼叫單元所造成的 Side Effect 結果」,是因為被呼叫的單元應該要測試自己的行為,在這邊不需要重複測試。 如果被呼叫單元的測試能夠保證行為正確,我們就只需要確保呼叫時的輸入正確就行。
有 Side Effect,取得外部狀態
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。
壞處有三:
- 一但 mock 特定的 module,整份測試檔案都會生效,也不管某些測試案例不想要 mock 的行為
- manual mock 被整個系統共用,沒辦法針對特出的場景設計 mock (待確認)
- 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 了
- 沒有使用 dependency injection
- 該 module 的不同的 methods 在同一個測試案例中被使用,且 module 內部會共享狀態
- module export default 一個 function,在測試被使用
上面的情境要怎麼 mock,就遇到了再說吧~~~~~~~。
結語
本篇文章講了 unit test 中抽象的概念,暸解這些概念可以在不同的系統之間舉一反三。 但如何應用在不同的 tech stack,這就需要在實踐中自己細細摸索,從經驗中找出自己的一套方法。
現在 React 是世界上最為流行的 framework 之一,如何在 React 中實作測試就顯得特別重要,沒有描述在使用 React 下如何測試元件是本篇文章的遺珠之憾,希望有時間能補上,讓本篇文章更完整。