前言

CukeDedup 是一個 CLI 工具,會對 Cucumber/Gherkin 專案中的 step 定義做靜態分析,找出重複、模糊不清、可再利用的可能性,以及未使用的定義。它是用 Rust 撰寫,以 MIT 授權釋出,並公開在 GitHub 上。

不需要設定檔。只要在測試專案的根目錄執行以下指令即可使用。

npx cuke-dedup .

image.png

範例情境使用的是日文 Gherkin(# language: ja),但其運作方式和英文完全相同。

這篇文章主要從我的個人經歷出發,談談我為什麼會做這個工具,以及實際套用在 OSS 專案上之後學到了什麼。至於更詳細的使用方式與內部實作,我打算另外再寫一篇文章介紹。

契機:Gherkin 很好寫,但持續整理很難

之前我擔任前端 lead、協助導入 E2E 測試時,選擇了 Cucumber 和 Gherkin。目的不只是自動化測試,而是希望建立一個能讓 QA 和產品成員也一起參與情境討論、用共同語言描述行為的環境。

Feature: User authentication

  Scenario: Accessing a protected page
    Given the user is authenticated
    When the user accesses the dashboard
    Then the dashboard should be visible

這個目標本身是成功的。不過,過去的經驗也讓我預想到一些問題:寫新的測試很簡單,但要讓規模變大的測試套件維持整潔,則是另一回事。

Cypress 或 Playwright 的基本測試寫法,即使是經驗較淺的工程師也能相對快速上手。真正困難的是「已經有什麼」的掌握。要在實作新的 step 之前,判斷是否已有類似的 step、既有實作能否重用或參數化,需要對程式碼庫有足夠理解。

Given('the user is logged in', async () => {
  await login();
});

Given('the user is authenticated', async () => {
  await login();
});

Given('an authenticated user', async () => {
  await login();
});

這三個都能正常運作,但真的需要三個嗎?

有經驗的工程師會立刻察覺,但對剛加入專案的人來說並不明顯。結果就是,這類問題往往只能在 code review 時才第一次被指出。而我作為 reviewer,花最多時間的也正是這一塊。

程式碼重複,和行為重複,是兩回事

我很重視 lint 工具和靜態分析工具。就算有規範,如果沒有辦法驗證,開發者通常要到送出 PR 之後才會發現違規。reviewer 也得花時間指出機器本來就能找出的問題。如果規範不是「reviewer 的個人解讀」,而是「專案明確的共識」,團隊內的摩擦也會少很多。

當時我組合使用 ESLint、Danger.js 和 jscpd。特別是 jscpd 對程式碼重複偵測很有幫助,但也有其限制。即使 jscpd 顯示重複率是 0%,還是可能留下像這樣的 step 定義:

When('I create a red project', async () => {
  await createProject('red');
});

When('I create a blue project', async () => {
  await createProject('blue');
});

這些很可能可以收斂成一個參數化 step。

When('I create a {word} project', async color => {
  await createProject(color);
});

反過來說,即使字面上非常相似,也不代表那些 step 就應該合併。因為在領域上,它們可能代表的是不同概念。我需要的是一個不只看文字相似度,也能一起觀察實作特徵的工具。

從手邊腳本到 OSS

一開始只是一些零散的小腳本:計算字串相似度、正規化不同寫法、找出未使用的定義、簡單比較各個 handler 之間的差異。這些東西多少有幫助,但還不足以看出整個套件的結構與可重用機會。

在現職協助建立新的 E2E 測試基礎設施時,我知道自己終究會再次碰到同樣的問題。和其他團隊的人聊過之後,也發現大家都有類似困擾。於是我決定把過去的經驗整合成一個工具。

我從一開始就打算把它做成 OSS。如果只是自己用的小腳本,我清楚限制,寫得比較隨便也無妨;但如果要公開,就必須做成即使不知道我的背景的人也能理解並使用的工具。這份責任感,也成了我重新整理設計的好契機。

在此之前,我公開過的多半是比較小型的專案,所以能設計一個稍微複雜一點的工具,並持續把它養大,對我來說也是很有趣的挑戰。

另一個個人原因是,我一直想用 Rust 實作一個正式工具。因為它是會在開發中反覆執行的工具,所以我希望使用能好好控制速度與記憶體的語言。解析器則採用 tree-sitter。雖然中途也考慮過換成 oxc 或 swc,但最後選擇保留 tree-sitter 的錯誤恢復能力,因為它即使面對有語法錯誤的檔案,仍能擷取出可解析的部分。

設計時重視的事情

能不設定就開始使用

我參考的是 jscpd 的使用體驗。只要輸入 jscpd .,就會直接得到有用的資訊。想找重複的人,是想解決自己專案的問題,不是想先學一個新工具的設定方式。

CukeDedup 會尊重 .gitignore,並自動辨識常見的目錄結構,以及 Cucumber.js、Playwright-BDD、Cypress Cucumber 等設定。設定檔的存在,是為了處理例外與細部調整,不是啟用工具的前提。

提供根據,而不是只做判斷

我不想只做出一份單純說「這兩個很像」的報告。我希望它能指出定義在哪裡、比對了哪些 matcher、是根據什麼理由得出這個結論,並在可能時附上值得評估的建議動作。

但最終判斷仍交給工程師。即使實作很像,從領域角度看也可能是不同概念;也不代表所有能抽象化的機會都一定值得做。

能說「不知道」

靜態分析有些事情一定得執行後才知道,例如動態設定、包裝函式、無法 resolve 的 import 等。遇到這些情況,CukeDedup 不會說「沒問題」,而是回報分析不完整(incomplete)。加上 --fail-on-incomplete 後,CI 就可以把這種情況視為失敗。

檢出結果為零,不代表問題為零。當工具沒把握時,我認為比起裝作一切正常,更重要的是老實說出「無法判斷」。

可偵測的內容

目前版本(v0.12.0)共有 8 條規則。完全一致的衝突預設為錯誤,基於 heuristic 的偵測則預設為警告。

規則可偵測的內容duplicate-matcher具有相同 matcher 的定義normalized-matcher正規化後會變成相同 matcher 的定義duplicate-handlermatcher 不同,但實作相同的定義near-duplicate-stepmatcher 與實作都很接近的定義parameterization-candidate從 matcher 與實作結構看起來可透過參數化重用的定義ambiguous-step單一 step 對應到多個定義的狀態overlapping-matcher可能匹配到同一字串的 matcher 組合unused-definition沒有匹配到任何 step 的定義duplicate-handler 並不一定表示一定要刪掉定義。有些情況下,可能只是想保留不同的說法,作為領域語彙的一部分。不過,即使如此,這仍然可能是共用實作的契機。

當然,CukeDedup 並不理解自然語言的語意。例如它不會判斷 logged in 和 authenticated 是否代表同一件事。它所報告的,是像「實作相同」這類靜態資訊,再由此提示值得進一步確認的關係。

unused-definition 也前提是你有把相關檔案納入解析範圍。因為它有可能在未包含的 feature 檔中被使用,所以工具在這類情況下也會謹慎回報。

當你修正完指摘的問題後,再執行一次同樣的指令就可以了。

image.png

試著用看看

在 JavaScript/TypeScript 專案中,可以直接從 npm 使用已編譯好的原生 binary。如果有 Rust 開發環境,也可以透過 Cargo 從原始碼編譯安裝。

# npm
npm install --save-dev cuke-dedup
npx cuke-dedup .

# Cargo
cargo install cuke-dedup
cuke-dedup .

目前支援 Cucumber.js、Playwright-BDD、Cypress Cucumber、Jest-Cucumber、Vitest Cucumber Plugin。除了一般的 .feature 檔,也能讀取 Gherkin Markdown(.feature.md)。

報告格式可以依用途選擇。

形式主要用途terminal開發中的快速回饋、CI loghtml可搜尋報告中的目視確認與分享json結構化整合jsonl shell 或 AI agent 的串流處理sarif GitHub code scanning 等平台```
cuke-dedup . --reporters terminal,html


![image.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88535%2F7a530e9b-7b4c-49ee-ae6a-e5dccd2dce3d.png?ixlib=rb-4.1.1&auto=format&gif-q=60&q=75&s=602a752670bc619d66709f5ec216a4ba)

### [](#ci-%E3%81%AB%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%80)整合到 CI

我提供了 GitHub Action。它會下載原生 binary,先驗證 checksum 與 build provenance,再執行。最小化的 workflow 如下:

.github/workflows/cuke-dedup.yml

name: CukeDedup
on: pull_request

permissions:
contents: read

jobs:
cuke-dedup:
runs-on: ubuntu-latest
steps:

  • uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
    with:
    fetch-depth: 0
    persist-credentials: false
  • uses: figueiredoluiz/[email protected]
    with:
    threshold: 5

共有三種結束代碼:0 代表品質閘門通過,1 代表因閾值或錯誤規則而失敗,2 代表設定或解析失敗等作業層級的錯誤。只有警告不會讓結果變成 1。

我也很重視發佈物的安全性。每個 release 成品都附有 SHA-256 checksum、Sigstore 簽章,以及 GitHub build provenance。因為這是會在開發者機器或 CI 上執行的工具,我希望使用者能驗證下載到的 binary 是否真的是原始發佈的那一份。

既有專案可先用 baseline

在遺留專案裡,要等所有既有重複都清掉再導入工具,幾乎不現實。所以我做了 baseline 功能:先記錄目前已存在的檢出結果,之後只讓新增加的內容失敗。這樣就能不是一次償還所有技術債,而是先從「不要再增加」開始。

image.png

在實際 OSS 上跑過後學到的事

只用自己準備的範例來測試,工具自然會「剛好」表現得正確。所以我拿它去跑了 92 個使用 Cucumber 系工具的 JavaScript/TypeScript OSS repository。這不是為了統計整個生態系,而是想把工具丟進我不熟悉的寫法裡實際測試。

結果也讓我看見了 CukeDedup 本身可以改進的地方。

命名規則一致時,會「看起來很像」

某個專案裡,near-duplicate-step 被偵測出 138 筆,而且全部的 matcher 相似度都在 0.90 以上。原因是它用了很一致的命名規則,對不同元素寫出了形式相同的 step。

我發現只靠文字接近還不夠,因此把實作(handler)的相似度下限從 0.50 提高到 0.70(可透過設定調整)。這是為了減少誤判可能性較高的檢出;在我分析的語料庫中,這條規則的檢出數量大約減半。當時的回歸測試仍維持了預期的檢出結果。

把值拿掉後,無關的實作也會看起來一樣

parameterization-candidate 會把實作中的部分 literal 值抽掉來比較結構。結果連像只是單純呼叫一次 cy.log(...) 的無關小 handler,也被判定成「可參數化」。在某個專案中,352 筆檢出裡就有 169 筆屬於這條規則。

因此我加入了更多條件:必須是同類型的 matcher、不能有像 not 或 un- 這類否定對立,且差異必須集中在單一位置。根據我分析的語料庫,這條規則的檢出數量大約減少了 40%,而預期的檢出結果也仍透過回歸測試維持住了。這也讓我更明確:我不想做成一個會隨便鼓勵泛化抽象的工具。

「模糊」其實是解析範圍的問題

有兩個專案分別出現了 2,263 筆和 1,563 筆 ambiguous-step。查過之後發現,原因是我把實際執行時屬於不同 suite 的定義,當成同一個 registry 來處理了。只要改成依照真正的執行單位來分析,結果就變成 0 筆。

真正該修的不是演算法,而是對解析範圍的理解。靜態分析工具不只要讀懂程式碼,也要理解專案結構中哪些關係才真正有意義。

被 bundle 起來的 jQuery 讓它卡了 20 分鐘

有兩個 repository 等了 20 分鐘以上都沒有出報告,原因是裡面包含了被 bundle 起來的 jQuery 等檔案。

一開始我嘗試用檔案大小或行長來判斷,但很快就遇到反例:89KB 的真實檔案會卡住,1MB 的合成檔案卻幾秒就跑完。問題不在輸入大小,而在解析處理的時間複雜度。修正之後,jQuery 的解析只要 0.6 秒。這段調查過程的細節,我之後也會另外寫文章分享。

對於被判定為產生物、並以內容為基礎過濾掉的檔案,我會回報原因,並明確說明分析是不完整的,不會默默排除。

記憶體使用量

在一個使用 2 萬筆 step 定義的 benchmark 中,我重新調整了正規表示式的編譯方式,將峰值記憶體大約降到原本的 1/6。

我是如何維持品質的

把測試語料庫當成「規格」

改善某條規則後,順手把別的地方弄壞並不罕見。某個修正可能讓工具更會找出重複,卻開始回報那些其實應該獨立存在的實作;反過來,降低誤判的修正,也可能讓真正的問題漏掉。

因此我維護了一套期望行為的語料庫,裡面同時包含「應該被偵測」與「不應該被偵測」的案例。重點不是把現在實作輸出的內容直接凍結成答案,而是寫出理想的行為。還無法處理的情況,我會明確標成已知漏判(known miss)。如果在 OSS 調查中找到可重現的問題,我也會盡量縮成最小測試案例,避免未來再回歸。

那組測試真的能偵測問題嗎

即使有語料庫,如果測試本身無法抓出回歸也沒有意義。因此我會故意破壞判定相關的處理,確認測試確實會失敗。被弄壞後還是綠燈的測試,其實什麼都沒守住。

我特別注意的是「不應該被偵測」的案例。就算要比較的對象根本沒被解析,結果也同樣會是「未偵測到」。所以我會把這類案例和對應的「應該被偵測」案例配對確認,確保它真的只是在驗證預期的差異。

和 AI agent 一起使用

AI agent 現在寫測試和提出重構建議的能力都很強。也正因如此,我認為更需要快速、便宜、且具決定性的靜態分析作為防線。對於工具已經能穩定找出的重複,沒必要讓 agent 去讀上百個檔案慢慢猜。工具負責提供根據,agent 則專注在需要解讀的判斷上。這就是分工。

CukeDedup 可以輸出 JSONL 格式,內容包含位置、根據和建議動作;另外也有面向 agent 的 Agent Skill。

npx skills add figueiredoluiz/cuke-dedup --skill cuke-dedup

這個 skill 會引導 agent 把檢出結果當作判斷依據,而不是簡單理解成「所有相似的東西都要合併」。在動手修改之前,也會要求 agent 先確認相關檔案,這同樣是基於相同的設計理念。

接下來

目前主要支援 JavaScript、TypeScript,也有實驗性支援 Cucumber-Ruby。接下來我希望擴充更多框架,並評估延伸到 Java、PHP 等其他語言。

另外我也在考慮以下幾件事:

  • 降低誤判,並擴充測試語料庫
  • 公開針對 OSS 專案調查後的報告
  • 與各專案一起修正調查中發現的問題

我希望這些工作都能在維持一開始就很重視的「導入容易、彈性高、速度快」前提下持續推進。

結語

我做 CukeDedup 的原因,是希望減少 code review 裡一再重複同樣指摘的時間,把心力留給真正需要判斷的討論。把它做成 OSS,也讓我有機會分享一路上的學習。

如果你有在使用 Cucumber、Gherkin 或這些支援的框架,歡迎試試看。那些沒能成功解析的案例,也會是很有價值的回饋。無論是誤判回報、尚未支援的寫法分享、功能建議、還是程式碼貢獻,我都很歡迎。

另外,CukeDedup 是我個人的獨立專案,與 Cucumber 專案或其維護者沒有關聯。

參考資料


原文出處:https://qiita.com/figueiredoluiz/items/6337f9c909fe0f60e7c2


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝45   💬1  
731
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登