關於程式碼覆蓋率這件事,我一直都是只有聽過但是沒有自己落實過,一直到最近我在 BP Studio 上面引入了 Istanbul 來檢查並且陸續試圖推進覆蓋率、這才震撼地親身體會到這個指標有多麼重要。本篇就來分享一下這部份的心得。

撰寫測試

首先在講測試覆蓋率之前,當然前提是專案裡面要先要有「測試」這個東西。BP Studio 打從專案第一次 Git 認可以來就已經有針對一些部份做單元測試了,但是在一段時間當中我並沒有使用任何既有的測試框架,而是等於自己刻了一個功能非常精簡的測試框架出來。這當中的原因無它,不過就是因為我並不是很清楚引入測試框架有些什麼好處。

一直到了 2021 年底我正在開發 BPS v0.5 版的時候,我才引入了 Mocha 來重構測試。這裡頭的設置上花了不少功夫才做到位,但是結果很值得。採用發展完備的測試框架來寫測試的好處包括:

  1. 可以很容易地執行單一或者一部分的測試、以加快開發迭代,修正 bug 之後可以只跑上次失敗的測試,而不需要每次都要把整包測試都跑一遍。
  2. 使用發展完備的斷言(assertion)程式庫語法可以用更自然的語言描述「預期什麼樣的結果」,而不是千篇一律地「預期(某某不好閱讀的運算式)結果為真」。
  3. 底層有做好一些機制可以平行地執行多個測試,當專案的測試數目很多的時候可以更有效率(不過啟動平行執行有其額外開銷,所以測試較少的情況中這樣執行可能反而比較慢)。
  4. 跟 IDE 可以有好的整合:以 VS Code 為例,只要安裝 Mocha Test Explorer 這個擴充,就可以直接在側欄當中清楚地看到整個專案中所有的測試與它們的狀態(如下圖所示),更方便單獨執行和偵錯。

file

根據 NPM trends 上的資料,截至本文撰寫為止,最受歡迎的測試框架為 Jest,其次為 Mocha。原本 Mocha 是最大宗的測試框架,但到 2019 年左右被 Jest 超越,之後距離就越拉越開。事到如今,我也不是很記得當初為什麼我選擇採用 Mocha 而非 Jest,好像是當時人們在講說 Mocha 的擴充性比較好,但是現在就我的觀察,最新版本的 Jest 在功能上實在沒有什麼輸給 Mocha 的地方,而且 Jest 的優勢在於一些模組都內建整合好了,不像 Mocha 還要個別自己加以配置。也許未來哪一天我也會跳槽到 Jest 也說不定,但是既然 Mocha 暫時也都還是用得沒什麼問題,我就先繼續用吧 1

但是,就像我剛才說的,Mocha 為了追求彈性,很多東西都沒有內建。以 BPS 來說,我安裝的東西包括:

  • mocha:框架本身
  • chai:最常跟 Mocha 一起搭配使用的斷言庫(替代選項尚有 should 等等)。
  • ts-node:以便直接執行 TypeScript 撰寫的測試檔案,並能整合 IDE 偵錯功能。
  • tsconfig-paths:為了讓 ts-node 認得 TypeScript 裡面的一些自訂的模組路徑寫法。
  • @types/mocha:以便讓 IDE 認得 Mocha 註冊的全域函數。
  • @swc/core:讓 ts-node 改用 SWC 的轉譯器處理 TypeScript,執行起來會比較快。
  • mocha-suppress-logs:這個可以防止跑測試的時候輸出程式本身輸出的 log,讓畫面變乾淨。

所以會看得出來真的是要自己裝滿多東西的。

(這邊尤其要特別提到 Chai:它在本文撰寫的期間剛好升級到了 v5 版,這個版本主打「全面移轉至 ESM,且只支援 ESM」,這我也不知道 Chai 的開發團隊在想什麼、他們究竟是沒有充分意識到、這樣的決定會給既有的使用者帶來多少麻煩,還是說他們知道、但是總之不在乎?在整個 Node.js 的工具鏈生態系裡面,還是有著太多的工具只支援 CommonJS 的模組寫法而無法支援 ESM,因此可以預期地會有一大堆人跟我一樣遇到「沒辦法把 package.json 直接設置成 "type": "module"」的困境,但偏偏 Chai v5 預設就是希望用的人這麼做。給他們這麼一搞,弄得我研究了若干天才弄到讓它可以在不修改 package.json 的情況下跟其它工具和平共處。底下收錄的咒語會順便說明配置的方法。)

裝好這些東西之後,在專案中加入 .mocharc.json 設定檔 2

// 真正的 mocharc 檔案是不能有註解的,所以如果各位要抄這一段,記得註解都要拿掉
{
    "extension": [
        "ts"  // 設定支援 ts 檔案
    ],
    "spec": [
        "test/specs/**/*.ts"  // 設定測試檔的路徑(以 BPS 為例)
    ],
    "timeout": "0",
    "require": [
        // 底下這幾個的順序有差(有點難以解釋,但總之就是這樣)
        "./test/mocha.env.mjs",   // 我們自訂的組態檔,等一下說明
        "ts-node/register",       // 註冊 ts-node
        "tsconfig-paths/register" // 註冊 tsconfig-paths
    ]
}

其中那個 mocha.env.mjs 大致是長這樣的:

// chai 僅在這邊引入,就不會有 ESM vs CommonJS 打架的問題
import { Assertion, expect } from "chai";

// 這兩個是跟 ts-node 有關的設定
process.env.NODE_ENV = "test";
process.env.TS_NODE_PROJECT = "test/tsconfig.json";

// 然後把需要用的 chai 成員註冊成全域成員,如此一來就不用在測試檔案中個別引入
globalThis.Assertion = Assertion;
globalThis.expect = expect;

當然在我的測試資料夾當中還需要一個額外的定義檔 chai.d.ts 來宣告那兩個全域成員:

import type * as chai from "chai";

declare global {
    declare const expect: typeof chai.expect;
    declare const Assertion: typeof chai.Assertion;
}

然後再搭配類似這樣的 tsconfig.json,一切就設置完成了:

{
    "compilerOptions": {
        // ...
        "types": [
            "mocha", // 裡面有宣告 mocha 的全域函數如 describe 等等
            "node"
        ]
    },
    "include": [
        "./**/*.ts"
    ],
    "ts-node": {
        "files": true,
        "swc": true // 設定使用 SWC 來轉譯
    }
}

然後就可以在 CLI 中用 pnpm mocha 或是從 VS Code 的測試面板當中執行 TypeScript 寫的測試檔案了。

Istanbul 的安裝與設定

複習一下、所謂的覆蓋率指的就是「測試程式在執行的過程中,跑過了專案主體程式當中的多少敘述、邏輯分支、函數等等的佔比」而言。為了要計算覆蓋率,就需要有另一個程式去監視測試程式的執行過程、並且紀錄下來每一個曾經被執行過的敘述。在 JavaScript 生態系當中,專門做這件事的主要就是 c8 跟 Istanbul 這兩個。先前 Istanbul 無疑是大宗,但是在 2022 年間 c8 的熱門度迅速竄升超越了前者。由於 c8 很多地方效法了 Istanbul,所以執行上與輸出報告的方式都是差不多的,而兩者的差異在於:

  • c8 利用的是 Node.js 跟 V8 引擎原生的覆蓋率機制,所以效能比較好且可以支援 ESM;相對之下 Istanbul 則是自己去擴充要執行的程式碼來做到側錄的目的,效能會稍微差一點、且目前為止尚不支援 ESM(不過,只要轉譯 TypeScript 的時候仍舊輸出成 CommonJS 就沒有這個問題了,所以這個弱點不算大)。
  • 兩者在忽略(ignore)的語法上有點不太一樣。Istanbul 的語法是區塊導向的(例如「忽略下一個區塊」),而 c8 則是行導向的(例如「忽略以下的 5 行」)。我個人覺得 Istanbul 的語法明顯比較直覺且好維護,而且 Istanbul 的 ignore else 的這種寫法據我所知在 c8 當中沒有對等的寫法。這些語法差異算是我選擇了 Istanbul 而非 c8 的最主要原因。

Istanbul 使用起來非常簡單:先安裝 nyc(雖然名字完全不一樣,但它就是 Istanbul 的 CLI 工具),然後直接執行 pnpm nyc mocha 這樣就可以了。

剩下唯一需要設定的,大概就是要用什麼樣的方式輸出報告了:預設是採用 text 模式輸出,而如果要跟 VS Code 的擴充例如 Coverage Gutters 整合的話,就需要使用 lcovonly 的報告器來輸出 .lcov 檔案。不過,像 Coverage Gutters 這類的擴充雖然可以很方便地在 VS Code 編輯器當中直接看原始碼的覆蓋概況,如果真的要查看細節的話,還是推薦 Istanbul 產生的網頁報表:選擇 html 報告器的話(或者選擇 lcov 報告器以同時輸出 .lcov 檔案和網頁),Istanbul 就會產生一套非常詳盡的網頁報表可以完整理解各資料夾與程式碼的覆蓋狀況(尤其是精確來說到底是哪裡沒覆蓋到了),很有助於我們提昇覆蓋率。報告器也可以同時選取多個,例如:

pnpm nyc -r lcovonly -r text-summary mocha

Istanbul 也可以透過 .nycrc 檔案來設定一些包括報告器在內的東西,這部份可以參考其文件。值得一提的是,有了 .nycrc 檔案之後,就可以結合 shields.io 來給專案產生像這樣的徽章:

覆蓋率的重要性

那終於要談到重點了:到底為什麼我們應該要在乎測試覆蓋率這個東西?就我自己的心得來說,至少可以整理出幾個動機:

  1. 釐清真的不會執行到的程式碼
    在談效能的時候,一個基本的常識就是「if 的代價其實頗昂貴的」;這個平常當然感受不到,但是如果一個複雜的演算法執行了數萬次迭代、每次迭代當中都多了一個其實不必要的 if 判斷,那效能的差距就會看得出來了。關注覆蓋率能夠讓我們發現、有一些判別區塊其實在所有的測試案例當中都沒有被執行到,這個時候我們就可以從中仔細思考,是不是有什麼邏輯上的必然性、使得那個區塊對應的情況根本就不可能發生,從而可以放心地拿掉該判別,增進演算法的效能。
  2. 抓出實務上很難觸發的 bug
    然而,也有的情況是,一個區塊雖然在所有既有的測試案例中都沒有被執行到,但其進入條件並非真的完全不可能發生、只是實務上非常不容易出現而已。而基於這個性質,如果那個區塊裡面的程式碼其實有 bug 存在,我們在開發的過程中也幾乎不會發現。為了推進覆蓋率,我們會被鼓勵去設計出一些能夠進入該區塊的特殊測試案例,這個時候就能夠發現區塊當中的 bug。
    在 BP Studio 的開發過程中,即便我已經非常努力地實測、也已經透過了前一篇提到的方法來收集一些很難觸發的錯誤,我還是很驚訝地發現,自從我開始追求覆蓋率以來,我還是在上述的過程當中找到了將近十個演算法當中隱藏的 bug。
  3. 能更仔細地考慮分支的正反面
    有些 if 判別式並沒有對應的 else 區塊,這很容易造成思路上的盲點,讓我們只去思考 if 的情況當中發生的事情、卻忘了思考要是 if 不成立的話、是不是真的就什麼都不做也沒關係。Istanbul 的覆蓋率報告會清楚地呈現出邏輯分支並沒有正反兩面都被執行到的地方,而如同前述,當我們故意去設計一個能夠繞過 if 的條件的測試案例時,有很大的機會就能發現 else 的情況並非真的如同我們原來想像得那樣是一個可以省略的區塊。
  4. 引導出好的程式碼重構
    如果我們的程式碼重用度低,在每一個邏輯分支上都寫了很多類似的東西,我們就會發現,要撰寫各種測試案例、使得那些重複的程式碼通通都被執行到、真的是一件很煩人的事情。明明是很類似的程式碼,一種情況有被測試到了、那別的情況不是也理應就對了嗎?我們自己當然會這樣想,但是覆蓋率跑出來卻是遍地紅色。追求覆蓋率會激勵我們去把那些有一定重複程度的程式碼抽離出來重用,因為如此一來抽出來的部份有在一種情況中被執行到就是綠燈過關了;而這樣的重構對於整個程式碼的品質來說也是一件好事,因為它會提昇可維護性並且降低程式碼在複製與些微修改的過程中出錯的機會。我記得我在追求覆蓋率的過程中,至少就因此發現了一個地方是我複製貼上並且修改錯誤的例子。

  1. 況且真的要說的話,我還是覺得 Mocha + Chai 的斷言語法比 Jest 內建的斷言語法要來得好閱讀一些。當然 Jest 裡面也是可以用 Chai,但這樣一來的話不就有點失去用 Jest 的意義了? 

  2. 注意到我並沒有把 mocha-suppress-logs 加入到 require 清單中,因為我並非總是需要隱藏程式本身的輸出、有的時候我還是會需要看的。所以我只有在 package.json 的腳本當中會寫例如 mocha --require mocha-suppress-logs 來引入。 


分享此頁至:
最後修改日期: 2024/03/13

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。