遷移至 nukadoko
適用情境
將現有套件逐步、一項一項地遷移至 nukadoko,而非全面重寫。遷移的起點分為兩種:
- cucumber-js 套件 — 包含 feature 檔案和 glue 程式碼,通常搭配 Playwright 使用。
- 已具備型別步驟形態的套件 — 擁有自己的 DSL,雖然不是 cucumber 的,但足夠接近 nukadoko 的
defineStep,因此遷移主要是翻譯工作。
無論是哪種情況,遷移都必須分階段進行,絕不一次完成;本技能其餘部分會解釋原因,然後依序介紹每種起點的操作方式。
以下所有內容假設專案已經初始化。如果尚未初始化(沒有 nukadoko.config.ts),請先執行 nuka init。
兩個階段,絕不一次進行
一次只改變一件事,然後改變下一件 — 絕不一次改變兩件事。如果一個步驟在兩項變更同時發生後壞掉,就無法判斷是哪一項變更導致問題:原因會糾纏在單一失敗中。將工作拆分為階段,可以確保每次失敗都能追溯到唯一一項變更。這就是重點所在 — 階段的具體名稱是次要的,可追溯性才是必須維持的。
如果下面的階段不適用於你的起點,請根據這個原則推導出你自己的兩個階段。必須維持的是可追溯性,而不是這些特定的階段名稱。
以下提供兩種應用場景:cucumber-js 套件拆分為「切換匯入」和「加入型別」(如上所述);已具備型別步驟形態的套件則有不同的拆分方式,將在後續說明。
從 cucumber-js 套件遷移
這就是上述兩個階段命名的依據。
階段 1 — 讓它跑起來
變更每個 glue 檔案中的匯入:
// 之前:import { Given, When, Then } from "@cucumber/cucumber";
import { Given, When, Then } from "nukadoko/compat";
決定探索(discovery)行為的是副檔名,而不是套件本身的歷史:.ts、.mts、.js 和 .mjs 的 glue 都會以相同方式讀取,因此原本就是純 JavaScript 的套件不需要為了開始這個階段而改寫成 TypeScript。.cjs 檔案是唯一的例外:nukadoko 只支援 ESM,所以探索時永遠不會匯入 .cjs 檔案;nuka check 會明確指出這個問題(step-file-unsupported-extension),而不是讓它因為未說明的 undefined-step 錯誤而消失。將它重新命名為 .js 僅在該檔案本身的程式碼已經是 ES 模組語法時才有幫助;如果它仍然使用 require(...),則在匯入時就會因為相同的 ESM-only 原因而失敗,此時 nuka check 會以 step-file-import-failed 錯誤訊息告知。
然後執行 nuka check <feature> 和 nuka run <feature>,並閱讀它們的輸出。修正它們指出的任何問題,然後再次執行。重複這個過程,直到現有套件變為綠色 — 這就是階段 1 的完成條件,僅此而已。這個條件是 nuka check 和 nuka run 變為綠色,而不是 tsc 型別檢查完全通過;兩者可能在某些情況下不一致。
在階段 1 的整個過程中,nuka steps 和 nuka describe 都保持可用,即使某些 glue 檔案仍然無法匯入:兩者都是逐個讀取步驟檔案,因此仍然無法匯入的檔案會被指名(--json 輸出為 import_failures,否則輸出到 stderr),而不是讓整個詞彙表變空。請邊進行邊閱讀詞彙表,而不是等到所有檔案都能順利匯入才開始;nuka run <feature> 和 nuka do <step> 在遇到無法讀取的 glue 檔案時仍然會直接拒絕執行,這是故意的,因為它們即將執行。
在開始之前,不要試圖尋找一份關於 compat 不支援什麼的完整清單。任何無法運作的東西都會大聲失敗,不是在匯入時就是在第一次 nuka run 時 — 失敗會指出是什麼東西壞了,這就是你需要處理的對象。這裡列出的清單在 compat 的功能範圍擴大後很快就會過時;而 CLI 自己的輸出永遠不會過時。
階段 2 — 賦予合約
套件可以執行後,某些步驟值得加上型別。以下是當一個步驟被升級時會發生的變化:
| compat 步驟 | 型別步驟 | |
|---|---|---|
| 輸入 | 僅模式捕獲,不檢查 | 根據 args schema 驗證,每個欄位都帶有 .describe() |
| 輸出 | 丟棄 — 收據的 result 為 null | 根據 returns schema 驗證,並儲存在收據中 |
| 依賴 | 對 World 的副作用,在函式簽章中不可見 | 透過 from 宣告,在匯入中命名,由 nuka check 在執行任何操作之前檢查,並記錄在收據的 used 中 |
| 關鍵字 | 裝飾性 — 繫結到 Then 的步驟仍然可以改變狀態 | mutates 是 nukadoko 信任的宣告:宣告 mutates: true 後,唯讀環境會拒絕執行它,且 nuka check 在它繫結到 Then 時會發出警告;實際執行什麼仍會記錄在收據的 observed 計數中 |
| 單獨執行 | 不可行(情境之外 World 為空) | nuka do <step> 直接執行它,收據輸出到 stdout;from 鍵來自 --args 或其他參數,或是來自 --use <receipt-id> 以使用先前執行的結果;在 run 內部透過 resultOf fixture 進行的呼叫仍然找不到東西,因為沒有情境可以讓它遍歷 |
最後一行是獨立於上面「依賴」行的事實,而不是它的後果:compat 步驟無法單獨執行,因為它需要的東西位於尚未被任何東西填入的 World 上,而 World 不是其簽章的一部分 — 沒有東西可以檢查以知道需要先設定什麼。型別步驟的依賴項被命名為 from 條目,在匯入中可見,因此 nuka do 呼叫可以手動提供它們:--args 用於一般鍵,或 --use <receipt-id> 用於從先前執行的結果中提取 — 上游步驟的名稱永遠不需要出現在命令列上,因為引用的收據已經攜帶了它。一個每個鍵都透過這種方式到達的步驟,根本不需要 --args:單獨使用 --use 就是一個完整的呼叫。在情境之外仍然找不到東西的,是在 run 內部透過 resultOf fixture 讀取的依賴項:當沒有情境來建立鏈時,該呼叫就沒有鏈可以遍歷,無論是否使用 --use。
首先升級你最常執行的步驟,一次一個 — 而不是一次升級整個套件。如何改寫任何特定的 glue 程式碼是你在當下需要做出的判斷;nukadoko 不規定單一配方,本技能也不會。
在草擬型別步驟的 run 時,有時會先落在尚未完全解構的未遷移 run(ctx, args) 形態上。nuka steps --json 已經會命名該草稿觸及了哪些 fixture,作為 needs_inferred:一個從 ctx 自己的成員存取中讀取的詞彙推測,與 needs: null 和解釋為什麼無法讀取實際合約的 needs_error 同時存在。這可以省去手動重讀整個主體以了解需要在解構簽章中加入什麼的麻煩,但它只是一個推測,而不是合約:它會漏掉別名(const c = ctx),且永遠不會命名 needs_browser,因此請完成解構,而不是將其視為完成。
升級後的步驟範例
一個範例。之前,compat glue:
const { Given } = require("@cucumber/cucumber");
Given("a project {string} exists", async function (name) {
const res = await this.request.post("/projects", { data: { name } });
this.projectId = (await res.json()).id;
});
之後,型別步驟(features/steps/create-project.ts):
import { defineStep } from "nukadoko";
import { z } from "zod";
export default defineStep({
pattern: "a project {name:string} exists",
description: "Create a project and return its id",
args: z.object({
name: z.string().describe("the project's display name"),
}),
returns: z.object({
id: z.string().describe("the created project's id, for later steps to cite"),
}),
mutates: true,
async run({ request }, args) {
const res = await request.post("/projects", { data: args });
return res.json();
},
});
變更了什麼:
this.projectId消失了;id 改透過returns回傳。後續步驟宣告from: { projectId: [createProjectStep, "id"] }以透過鍵讀取它 —nuka check在執行任何操作之前驗證繫結順序,且該讀取會出現在該後續步驟的收據中(resultOffixture 仍然可用於鍵名稱無法表達的少數讀取情況)。- 參數是一個命名捕獲,繫結到 schema 鍵(
{name:string}→args.name),因此模式本身就能顯示哪段文字對應哪個欄位。 mutates: true現在是一個宣告,而不僅僅是關於程式碼碰巧做了什麼的事實 — 如果這個步驟被繫結到Then,nuka check會發出警告,且唯讀環境會完全拒絕執行它。收據仍然會記錄實際傳送了什麼以供審查,但該計數不會導致步驟被拒絕。this.request變成requestfixture,透過解構第一個參數來命名:只有執行器實際注入的東西才能在那裡被命名,沒有隱含的東西,而且只有實際命名的名稱才會被構建。一個既未命名page也未命名context的步驟,永遠不會啟動瀏覽器。
這是這裡唯一的工作範例。它不是 compat 和型別之間所有差距的目錄 — 如果另一個模式經常出現而值得有一個範例,那將是一個單獨的補充,而不是從這個單一案例中即興發揮的東西。
當全域的 After 鉗子是清理手段時
cucumber-js 套件經常依賴全域的 After 鉗子來清理步驟建立的任何東西:租戶、種子資料行、暫存檔。一旦建立這些資源的步驟被升級,就值得重新審視這個做法:將資源宣告為 nukadoko.config.ts 中 fixtures 下的 fixture,並在與建構它的同一個函式中,於 await use(...) 之後直接撰寫清理程式碼,而不是將清理邏輯留在必須僅從標籤猜測哪些情境實際需要它的鉗子中。defineFixtures(來自 nukadoko 套件)使 fixture 完全型別化;任何型別步驟都像存取 page 或 request 一樣,透過解構名稱來存取它。這是附加的,而非強制的:一個清理尚未被任何型別步驟宣告的資源的 After 鉗子,在階段 1 中仍然完全有效。
從型別步驟形態 DSL 遷移
如果沒有 feature 檔案,也沒有 cucumber glue,則上述階段 1 中的 compat 入口不相關 — 請直接跳過它。
這是因為 pattern 在 defineStep 中是選用的:一個步驟可以在沒有任何模式的情況下定義,但仍然是一個完整的 CLI-only 詞彙,可用 nuka do 執行,並可用 nuka describe 檢查。這是根據相同原則產生的不同階段配對:首先將每個步驟遷移到型別化的 defineStep,然後在稍後當 feature 檔案使這件事有價值時,再將其捆綁到 Gherkin pattern 中。
如果來源 DSL 已經攜帶了類似 description、args、returns、mutates 和 run 函式的東西,那麼翻譯成 defineStep 是直接的 — 每個都有對應的 defineStep 欄位可以接收它。
不該做的事
- 不要同時進行階段 1 和階段 2。 在同一次變更中混合匯入切換和新增型別,正是讓失敗無法歸因的原因 — 請參閱上面的「兩個階段,絕不一次進行」。
- 不要讓套件在遷移中途停止執行。 Compat 和型別步驟可以在同一個 feature 檔案中共存;一個部分步驟已升級、部分仍為 compat 的套件,必須在整個過程中保持測試通過。絕對不要將「完全型別化」作為「可執行」的先決條件。
- 不要在其型別化的替代步驟撰寫完成並通過測試之前,就刪除 compat glue。 先撰寫新步驟,使用
nuka do讓它運作,然後才刪除舊的 — 順序絕不能相反。 - 不要猜測
nuka check或nuka run沒有要求你修正的內容。 它們的輸出是判斷什麼地方出錯的證據;更改它們沒有標記的內容,是基於直覺而非實際錯誤的變更。