測試產生器


測試產生器是一款 track 專屬的軟體,用來自動產生練習的測試。它的做法,是把練習的 JSON 測試案例轉換成該 track 程式語言的測試。

優點

擁有測試產生器有以下幾項好處:

  1. 可以更快新增練習
  2. 自動處理新增練習時「無聊」的部分
  3. 輕鬆讓測試與最新的標準資料保持同步

使用情境

一般來說,執行測試產生器的情況有兩種:

  1. 為_新_的練習產生測試
  2. 更新_現有_練習的測試

為新練習產生測試

為新練習加上測試產生器之後,就能產生它的測試檔案。只要測試產生器本身已經實作完成,替新練習產生測試,會比從零開始自己寫測試輕鬆得多。

更新現有練習的測試

練習有了測試產生器之後,你可以重新執行它,把練習更新/同步到最新的標準資料。我們建議定期這麼做,檢查是否有需要更新的問題測試案例,或是有沒有想加入的新測試。

起點

替練習實作測試產生器時,有 2 種可能的起點:

  1. 練習是新的,因此還沒有任何測試
  2. 練習已經存在,因此已經有現成的測試
Caution

如果已經有現成的測試,實作測試產生器時,要確保它產生的測試不會破壞現有的解法。

設計

大致上來說,測試檔案的產生方式有兩種:

  • 程式碼:測試檔案(大部分)是透過程式碼產生的
  • 範本:測試檔案(大部分)是使用範本產生的

我們發現,以程式碼為主的做法會讓測試產生器的程式碼變得相當複雜,而範本做法則比較簡單。

我們建議採用以下流程:

  1. 讀取練習的標準資料
  2. 排除練習的 tests.toml檔案中標記為 include = false的測試案例
  3. 把練習的標準資料轉換成範本可以使用的格式
  4. 把練習的標準資料傳給該練習專屬的範本

這個做法的主要好處,在於每個練習都有自己專屬的範本,這麼做可以:

  • 讓測試檔案的產生方式一目了然
  • 讓範本更容易除錯
  • 讓你修改範本時,不必擔心弄壞其他練習
Caution

設計測試產生器時,請盡量:

  • 把測試產生器內部對標準資料的前處理降到最低
  • 降低範本之間的耦合

實作

測試產生器通常(大部分)是用該 track 的程式語言寫成的。

Caution

你當然可以自由使用其他語言,但每多一種語言,就會讓這個 track 更難維護、更難讓人貢獻。 因此我們建議盡可能使用該 track 的程式語言,因為這樣維護和貢獻都會比較容易。

格式化

如果你的 track 有格式化程式碼的工具,可以考慮在渲染範本_之後_,把它當成後處理步驟來執行。

標準資料

測試產生器處理的核心資料,是練習的 canonical-data.json 檔案。這個檔案定義在 exercism/problem-specifications 儲存庫中,該儲存庫定義了許多 Exercism 練習共用的中繼資料。

Caution

不是每個練習都有 canonical-data.json 檔案! 如果沒有的話,你就得手動建立測試,因為沒有資料可以讓測試產生器處理。

結構

標準資料定義在一個 JSON 物件中。這個物件裡有一個 "cases"欄位,裡面放的是測試案例。這些測試案例(通常)會與你 track 裡的測試一一對應。

每個測試案例都有幾個屬性,其中最重要的是描述、屬性、輸入值和預期值。以下是 leap 練習的 canonical-data.json 檔案的(部分)範例:

{
  "exercise": "leap",
  "cases": [
    {
      "uuid": "6466b30d-519c-438e-935d-388224ab5223",
      "description": "year not divisible by 4 in common year",
      "property": "leapYear",
      "input": {
        "year": 2015
      },
      "expected": false
    },
    {
      "uuid": "4fe9b84c-8e65-489e-970b-856d60b8b78e",
      "description": "year divisible by 4, not divisible by 100 in leap year",
      "property": "leapYear",
      "input": {
        "year": 1996
      },
      "expected": true
    }
  ]
}

測試產生器的主要職責,就是把這份 JSON 資料轉換成該 track 專屬的測試。以下是上述 JSON 可能轉換成的 Nim 測試程式碼:

import unittest
import leap

suite "Leap":
  test "year not divisible by 4 in common year":
    check isLeapYear(2015) == false

  test "year divisible by 4, not divisible by 100 in leap year":
    check isLeapYear(1996) == true

canonical-data.json 檔案的結構有完整的文件說明,另外也有 JSON schema 定義。

巢狀結構

有些練習的標準資料會用到巢狀結構。這代表 cases 陣列中的每個元素可能是:

  1. 一般的測試案例(沒有子測試案例)
  2. 一組測試案例(包含一個或多個子測試案例)
Note

你可以透過檢查某個元素是否帶有某種類型專屬的欄位,來判斷它的類型。 最好的方法大概是看 "cases" 這個鍵,它只會出現在測試案例群組裡。

以下是巢狀測試案例的範例:

{
  "cases": [
    {
      "uuid": "e9c93a78-c536-4750-a336-94583d23fafa",
      "description": "data is retained",
      "property": "data",
      "input": {
        "treeData": ["4"]
      },
      "expected": {
        "data": "4",
        "left": null,
        "right": null
      }
    },
    {
      "description": "insert data at proper node",
      "cases": [
        {
          "uuid": "7a95c9e8-69f6-476a-b0c4-4170cb3f7c91",
          "description": "smaller number at left node",
          "property": "data",
          "input": {
            "treeData": ["4", "2"]
          },
          "expected": {
            "data": "4",
            "left": {
              "data": "2",
              "left": null,
              "right": null
            },
            "right": null
          }
        }
      ]
    }
  ]
}
Caution

如果你的 track 不支援把測試分組,你就需要:

  • 走訪/攤平 cases 的階層,只留下最內層(葉節點)的測試案例
  • 把測試案例的描述和它上層的描述合併,組出唯一的名稱

輸入值與預期值

測試案例的 input 和 expected 這兩個鍵,內容差異很大。大多數情況下,它們是純量值(例如數字、布林值或字串)或簡單的物件。不過,偶爾也會遇到比較複雜的值,可能需要先做一些前處理,例如虛擬碼中的 lambda、要對學生程式碼執行的一連串操作等等。

情境

測試案例有一個選填的 scenarios 欄位。測試產生器可以利用這個欄位,對特定測試案例做特殊處理。最常見的用途是忽略某些類型的測試,例如標記為 "unicode" 情境的測試,因為你 track 使用的程式語言可能不支援 Unicode。

完整的情境清單可以在這裡找到。

讀取 canonical-data.json 檔案

讀取 canonical-data.json 檔案有幾種做法:

  1. 直接從 problem-specifications 儲存庫取得(例如 https://raw.githubusercontent.com/exercism/problem-specifications/main/exercises/leap/canonical-data.json)。
  2. 把 problem-specifications 儲存庫以 Git 子模組的方式加到 track 儲存庫。
  3. 從 configlet 快取讀取。 快取的位置取決於使用者的系統,不過你可以用 configlet info -o -v d | head -1 | cut -d " " -f 5 以程式方式取得位置。

Track 專屬的測試案例

如果你的 track 想加入一些額外的、track 專屬的測試案例(標準資料裡沒有的),其中一個做法是建立一個 additional-test-cases.json 檔案,讓測試產生器先把它和 canonical-data.json 檔案合併,再傳給範本渲染。

範本

要使用的範本引擎通常取決於 track。理想上,你會希望範本越單純越好,所以不用擔心程式碼重複之類的問題。

範本會從測試產生器取得資料,測試產生器則會疊代這些資料來渲染範本。

Note

為了讓範本保持單純,在測試產生器這一側做一些前處理,或是定義一些「篩選器」或範本支援的擴充機制,可能會很有幫助。

使用 configlet

configlet 是 track 的主要維護工具,可以用來:

  • 為新練習建立練習檔案:執行 bin/configlet create --practice-exercise <slug>
  • 同步現有練習的 tests.toml 檔案:執行 bin/configlet sync --tests --update --exercise <slug>
  • 把練習的標準資料下載到本機(這是上述任一個指令的副作用)

因此,configlet 很適合和測試產生器搭配使用,打造出功能強大的工作流程。

命令列介面

你會希望使用測試產生器既簡單_又_強大。為此,我們建議建立一個或多個腳本檔案。

Note

你可以自由選擇最適合你 track 的腳本檔案格式。 shell 腳本和 PowerShell 腳本都是常見、效果也不錯的選擇。

以下是一個 shell 腳本的範例,它結合 configlet 和測試產生器,快速建立新練習的雛形:

bin/fetch-configlet
bin/configlet create --practice-exercise <slug>
path/to/test-generator <slug>

從零開始建立

開始建立測試產生器之前,我們建議你先看看幾個現成的測試產生器,了解其他 track 是怎麼實作的:

如果有任何問題,論壇是最好的發問地方。關於 Rust和 JavaScript 測試產生器的論壇討論,可能也很有幫助。

最小可行產品

我們建議以漸進的方式建立測試產生器,先從最小可行產品開始。最基本可行的版本,會讀取練習的 canonical-data.json,然後直接把那份資料傳給範本。

先專注在單一練習上,最好選像 leap 這種簡單的練習。等到這個能運作之後,再逐步加入更多練習。

並且盡量讓測試產生器保持越簡單越好。

Note

理想情況下,貢獻者只要貼上/修改現有的範本就能使用,不必了解測試產生器的內部運作方式。

使用或參與

使用或參與測試產生器的方式,依各個 track 而異。請在 track 的 README.md、CONTRIBUTING.md 或測試產生器程式碼所在的目錄中尋找說明。