Skip to content

bdd-with-vitest-cucumber

測試都加了還是出包?是不是少了流程測試? ​

大家好,我是鱈魚。( ´ ▽ ` )ノ

上次聊完突變測試,結尾說系統還是會有流程漏洞,那又是另一個故事。

今天就是那個故事。(ノ>ω<)ノ


某個平凡的早晨,你拿著早餐在電腦前準備開工。

結果 PM 回報:「這個月退款金額怎麼比營業額還高?」

馬上嚇得沒心情吃早餐了。(;´༎ຶД༎ຶ`)

明明測試都寫好寫滿了,怎麼會這樣勒?( ´•̥̥̥ ω •̥̥̥` )

先看看犯案現場 ​

退款這件事,資料結構長這樣,order.ts

ts
export interface Refund {
  amount: number;
  createdAt: string;
}

export interface Order {
  id: string;
  total: number;
  status: 'paid' | 'cancelled';
  /** 這筆訂單所有的退款紀錄 */
  refundList: Refund[];
}

export function getRefundedAmount(order: Order): number {
  return order.refundList.reduce((sum, item) => sum + item.amount, 0)
}

接著是退款檢查,refund.ts

ts
export type RefundResult =
  | { accepted: true; amount: number }
  | { accepted: false; reason: 'ORDER_NOT_REFUNDABLE' | 'INVALID_AMOUNT' }

export function checkRefund(order: Order, amount: number): RefundResult {
  if (order.status !== 'paid') {
    return { accepted: false, reason: 'ORDER_NOT_REFUNDABLE' }
  }

  if (amount <= 0 || amount > order.total) {
    return { accepted: false, reason: 'INVALID_AMOUNT' }
  }

  return { accepted: true, amount }
}

最後是實際送出退款,refund-service.ts

ts
export function submitRefund(order: Order, amount: number): RefundResult {
  const result = checkRefund(order, amount)
  if (!result.accepted) {
    return result
  }

  paymentGateway.refund(order.id, amount)
  order.refundList.push({ amount, createdAt: getNow() })

  return result
}

不到 30 行,邏輯清楚,命名整齊,看起來完全沒有嫌疑犯的樣子。(・∀・)

各位大大可以先停下來想三秒,猜猜看犯人是誰。(´,,•ω•,,)

第一關:單元測試 ​

用 Vitest 把每個函數測好測滿,邊界一個都不放過。

getRefundedAmount 很單純,加總跟空陣列兩條就打發了,重點在 checkRefund。

ts
// refund.test.ts
describe('checkRefund', () => {
  const order = createOrder({ total: 1000 })

  it('金額在範圍內就通過', () => {
    expect(checkRefund(order, 400)).toEqual({ accepted: true, amount: 400 })
  })

  it('剛好等於訂單金額也通過', () => {
    expect(checkRefund(order, 1000)).toEqual({ accepted: true, amount: 1000 })
  })

  it('超過訂單金額就擋下來', () => {
    expect(checkRefund(order, 1001)).toEqual({
      accepted: false,
      reason: 'INVALID_AMOUNT',
    })
  })

  it('金額是 0 也擋下來', () => {
    expect(checkRefund(order, 0)).toEqual({
      accepted: false,
      reason: 'INVALID_AMOUNT',
    })
  })

  it('已取消的訂單不能退款', () => {
    const cancelledOrder = createOrder({ total: 1000, status: 'cancelled' })

    expect(checkRefund(cancelledOrder, 400)).toEqual({
      accepted: false,
      reason: 'ORDER_NOT_REFUNDABLE',
    })
  })
})

上邊界、下邊界、狀態、剛好等於,全部顧到了。跑起來全綠,覆蓋率 100%。ᕕ( ᐛ )ᕗ

第二關:整合測試 ​

單元測試只驗單一函數,接著驗模組串起來對不對,金流和資料寫入一起看。

ts
// refund-service.test.ts
describe('submitRefund', () => {
  it('退款成功時,會呼叫金流並寫入退款紀錄', () => {
    const order = createOrder({ total: 1000 })
    const spy = vi.spyOn(paymentGateway, 'refund')

    const result = submitRefund(order, 400)

    expect(result.accepted).toBe(true)
    expect(spy).toHaveBeenCalledWith(order.id, 400)
    expect(order.refundList).toHaveLength(1)
    expect(getRefundedAmount(order)).toBe(400)
  })

  // 另一條測「檢查沒過時不呼叫金流、不留紀錄」,這裡先省略
})

模組確實串接正確,該呼叫的呼叫、該擋的擋、該記錄的記錄,也是全綠。( •̀ ω •́ )✧

第三關:突變測試 ​

看過上一篇的朋友一定會說,測試全綠不代表什麼,叫 Stryker 出來踢館。

bash
npx stryker run

Stryker 開始把程式改壞。!== 換成 ===、<= 換成 <、> 換成 >=、|| 換成 &&,能想到的壞事全做了一遍。

text
Ran 9 tests, 9 passed
All found mutants have been killed 🎉
Mutation score: 100.00%

全部擊殺,突變分數 100 分。(ノ>ω<)ノ

覆蓋率 100%、突變分數 100%、單元測試綠、整合測試綠。

那筆 1200 元的退款到底怎麼變出來的勒?ლ(・´ェ`・ლ)

第四關:流程測試 ​

前三關都在問「這段程式碼寫對了嗎」,現在換個問法,「顧客實際走一遍會發生什麼事」。

客服處理瑕疵品時,習慣分次退款,退一件算一件。

ts
// refund-flow.test.ts
describe('退款流程', () => {
  it('累計退款金額不應該超過訂單金額', () => {
    const order = createOrder({ total: 1000 })

    submitRefund(order, 400)
    submitRefund(order, 400)
    submitRefund(order, 400)

    expect(getRefundedAmount(order)).toBeLessThanOrEqual(order.total)
  })
})

紅了。(;´༎ຶД༎ຶ`)

text
AssertionError: expected 1200 to be less than or equal to 1000

犯人抓到了。

checkRefund 從頭到尾只檢查「這一次的退款金額」有沒有超過訂單金額,完全沒看過之前退了多少。

每一次退 400 都合法,三次加起來就退了 1200 出去。(°ㅂ°)

為什麼前三關全都擋不住 ​

路人:「所以突變測試和你一樣也是個廢物囉?(「・ω・)「」

鱈魚:「不能這麼說,它的作用不在此...等等你是不是說了啥?( ˘•ω•˘ )」


突變測試的做法是把已經存在的程式碼改壞,看測試會不會失敗。

而這個 bug 不在任何一行程式碼裡,而在不存在的那一行。

ts
// 這行從來沒被寫出來,所以 Stryker 沒有東西可以改壞
if (amount > order.total - getRefundedAmount(order)) { /* ... */ }

換句話說,突變測試只會問「你寫的這行如果寫錯了,測試攔不攔得住」,永遠不會問「你是不是少寫了一行」。

這類錯誤有個正式名稱,叫做遺漏型錯誤(Omission Fault)。

Just 等人在 FSE 2014 的論文《Are Mutants a Valid Substitute for Real Faults in Software Testing?》拿真實專案的 bug 對照突變體,只有 73% 對得上。剩下那 27% 裡,很大一塊就是這種「程式碼多了或少了」。


再往上看一層,三關失守的原因是同一個。

  • 單元測試問的是「這個函數照規格做了嗎」
  • 整合測試問的是「模組串起來還是照規格做嗎」
  • 突變測試問的是「規格裡的每個判斷,測試都驗到了嗎」

三個問題都很好,但共同前提只有一個,假設規格沒問題。

生動一點的比喻就是:

  • 單元測試測試「積木是否符合模具的形狀」
  • 整合測試確定「多個積木是否能正常組合」
  • 突變測試是「故意拔掉其中一個積木,看看會不會解體」

結果沒有人告訴你,組出來的椅子造型積木真的要拿來坐,一坐就解體,摔了個狗吃屎。( ;´༎ຶД༎ຶ )


三方各自都很合理,湊在一起才出事。

  • PM 想的退款是「顧客不要了,整筆退掉」,所以規格只寫「退款金額不得超過訂單金額」
  • 工程師為了保留彈性,把金額做成參數,讓未來可以退部分
  • 客服遇到三件商品壞一件,很自然就分次退,退一件算一件

三個人都在做對的事,但從頭到尾沒人想到「分次退款」該怎麼算。ლ(´口`ლ)

那就多寫流程測試? ​

既然只有第四關擋得住,答案好像很明顯。

問題是流程測試難寫,而且難的地方跟其他三關完全不同。( ˘•ω•˘ )


路人:「難在哪?剛剛那條不是三行就寫完了嗎?(・∀・)」

鱈魚:「難在兩件事,怎麼知道要寫哪幾條,還有寫完之後那條規則怎麼不蒸發。(´・ω・`)」

難處一:要寫甚麼? ​

前三關其實都有機械式的對應關係。

  • 單元測試看函數,有幾個參數幾條分支,就配幾條
  • 整合測試看串接路徑,一條路徑配一條
  • 突變測試根本不用你想,工具自己算

流程測試沒有這種對應,你沒辦法說「一個 service 配三條流程測試」,因為它的單位不是程式碼,是商業規則。( ˘•ω•˘ )

而商業規則不會自己從程式碼裡長出來,得有人挖。


路人:「釐清商業規則不是 PM 的事嗎?(´・ω・`)」

鱈魚:「是沒錯,但工程師實作時會遇到的細節比 PM 多很多,這很正常。( ˘•ω•˘ )」


需求會議通常這樣結束,主持人問一句「大家還有問題嗎」,沒人說話,散會。(´_ゝ`)

問題是沒想到的事情不會舉手,而規格的洞往往不是在會議室裡發現,是在寫程式的時候。

因為只有工程師有辦法逐行逼近實作細節,PM 想的是「顧客要退款」,你想的是「amount 這個參數要不要限制範圍」。(°ㅂ°)

別怕煩到 PM,該問就多問吧,記得把問題有條理地整理好再問。( •̀ ω •́ )✧

甚麼?你說 PM 回不知道要你自己決定...('◉◞⊖◟◉` )

至於怎麼讓這種對話變得有結構,BDD 圈有現成的做法,例如 Matt Wynne 的 Example Mapping,把規則和沒答案的疑問寫成不同顏色的卡片,桌上還有紅卡就代表這個 story 還不能開工。有興趣可以去看看。(´,,•ω•,,)

難處二:規則寫完就蒸發 ​

假設運氣好,那句話真的被問出來了,接下來呢?

expect(getRefundedAmount(order)).toBeLessThanOrEqual(order.total) 這行背後藏著一條商業規則。

一筆訂單的累計退款金額,不得超過訂單金額。

問題是討論歸討論,最後留下來的只有這行 expect。那張紅卡散會就丟了,需求文件沒補、會議記錄也沒人回頭看。( ˘•ω•˘ )

而且就算工程師真的把規則寫成註解,PM 和客服也看不懂 toBeLessThanOrEqual,沒辦法幫你檢查「你理解的規則跟我想的一樣嗎」。


要補的是這三件事。

  • 規則要用人話寫,PM 和客服才有辦法幫你檢查
  • 規則要獨立存在,不能散會就蒸發
  • 規則要跟著測試一起跑,不然遲早跟實作脫節

路人:「那開個 Notion 頁面寫下來不就好了?(・∀・)」

鱈魚:「你自己相信那份文件三個月後還會是對的嗎?(´_ゝ`)」


把常見做法擺在一起看就很清楚。

做法人話獨立存在跟著測試跑
Notion 或 Markdown 文件✅✅❌
程式碼註解✅❌❌
寫得很用心的 it 描述❌❌✅
Gherkin✅✅✅

前兩欄好處理,第三欄比較麻煩。

而第三件事之所以難,在於那份人話不能只是放在旁邊的說明,它必須是測試的執行來源。

放在旁邊的文件一定會過期,因為沒有任何機制強迫它更新。只有當「改壞那段文字,測試就會跟著壞掉」的時候,它才會有人維護。( ˘•ω•˘ )


但這又帶出下一個問題,自然語言要能執行,格式就得受限。

  • 太自由,機器讀不懂
  • 太嚴格,人就懶得看了,那還不如直接寫程式碼

Gherkin 是個折衷點,著重在三個關鍵字,剩下的部分可以自由發揮。

  • Given:前提是什麼
  • When:發生了什麼事
  • Then:應該得到什麼結果

是不是很眼熟?這就是每條測試都在做的 Arrange-Act-Assert。( •̀ ω •́ )✧

所以 Gherkin 並沒有發明新東西,它只是把測試本來就有的形狀,翻譯成 PM 也讀得懂的樣子。

把規則寫成 Gherkin ​

把剛剛那條規則寫下來,features/refund.feature

gherkin
Feature: 訂單退款
  身為 客服人員
  我想要 對訂單分次退款
  以便 處理單件瑕疵的狀況

  Rule: 累計退款金額不得超過訂單金額

    Scenario: 分次退款直到超出訂單金額
      Given 顧客有一筆 1000 元的已付款訂單
      And 這筆訂單已經退款過 800 元
      When 客服再申請退款 400 元
      Then 系統應該拒絕這筆退款
      And 訂單的累計退款金額應該還是 800 元

Feature 底下那三行是慣例的使用者故事,格式是「身為誰、想要什麼、為了什麼」,不影響執行,純粹讓讀的人知道這功能為何存在。


請特別看 And 這筆訂單已經退款過 800 元 這句。

在單元測試的思維裡,不太會存在這種「已經累積了什麼狀態」的案例。

單元測試基本上是「給我一組乾淨的輸入」,而真實的小蟲蟲們往往就藏在不乾淨的既有狀態裡。( ˘•ω•˘ )

Gherkin 的 Given 天生就在描述這件事,逼你去想「動作發生之前,系統已經經歷過什麼」。


這份檔案裡沒有一行程式碼,PM 看得懂、QA 看得懂、客服看得懂,所有人都看得懂!(應該吧 ( ◔ ௰◔))。

不過有件事得先講清楚,.feature 檔本身只是純文字,丟給 Node 不會有任何反應。

它得再補一層步驟定義,把每一句話對應到一段程式碼,測試框架才有辦法照著順序跑。

所以精確說法是,這份文件驅動測試執行,而不是文件自己會跑。

後面我們會來實際跑跑看。( •̀ ω •́ )✧

Gherkin 不等於 user story ​

路人:「等等,所以 Gherkin 不就是 user story 換個寫法?(´・ω・`)」

鱈魚:「剛好相反,它是 user story 沒講完的那一半。( •̀ ω •́ )」


Ron Jeffries 提過 user story 的三個 C。

  • Card:卡片上那句「身為誰、想要什麼」,故意寫得很短
  • Conversation:拿著卡片去跟人討論
  • Confirmation:討論完之後,講定怎樣才算做完

卡片刻意模糊,用途是提醒你去找人聊,不是拿來當規格。Gherkin 待在第三個 C,是那場對話談完之後的產物。(´,,•ω•,,)

User StoryGherkin Scenario
回答誰要什麼、為什麼什麼情況下會發生什麼
精確度刻意模糊刻意具體
能不能驅動測試不能接上步驟定義就能
數量一張卡一張卡通常對應好幾個 Scenario
壽命做完就歸檔要一直活著

最後一列最容易忽略,user story 拿來排程,做完就歸檔;feature 檔得比那張卡活得久。

所以 feature 檔要按行為領域分,不能按 story 或 sprint 分,不然衝刺一結束就滿地孤兒檔案。(´_ゝ`)

這套做法有名字 ​

用人話寫規則、開工前先講好、寫成可執行的驗收條件,這些都不是誰臨時想出來,背後有兩套發展了二十幾年的方法論,ATDD 和 BDD。

這兩個詞常一起出現,很多人分不清楚,還常看到「BDD 由 ATDD 演化而來」這種說法。它們其實是同期長出來的兄弟,各自從不同痛點出發,最後在同一個地方會合。(´,,•ω•,,)

ATDD ​

ATDD 全名是 Acceptance Test Driven Development,驗收測試驅動開發。

Kent Beck 在 2002 年的《Test-Driven Development: By Example》就提過,只是當時覺得不太實際。同年 Ward Cunningham 做出 FIT,讓客戶自己寫驗收測試,這條路才真的跑起來。

主張是開工前先把「什麼叫做做完」講清楚,寫成具體的驗收條件。

實務上最有名的做法叫「三個朋友」(Three Amigos),開工前由三種角色一起坐下來討論。

  • 業務代表(PM)說明想要什麼
  • 開發說明技術上做得到什麼
  • 測試負責問各種「那如果⋯⋯呢」

回頭看看那 1200 元。

「如果客服分三次退,每次都退 400 呢?」


路人:「這種案例我自己也想得到啊,開會幹嘛?(´・ω・`)」

鱈魚:「對,你想得到,這跟有沒有跑 ATDD 一點關係都沒有。ヽ(́◕◞౪◟◕‵)ノ」

路人:「...(´・ω・`)」


說實話 ATDD 不會讓你變聰明,會問出那句話的還是人,流程沒辦法自動思考。

細心的工程師自己泡咖啡時就想到了,而開了會卻全場沒人提很常見。

不過 PM 想流程、業務想實際操作,多個腦袋湊在一起,比一個人埋頭寫程式更容易冒出那句話。

這是機率,不是保證。乁( ◔ ௰◔)「

當然可以請 AI 一起幫忙想,不過最終只有人有辦法跟客戶確認。

BDD ​

BDD 全名是 Behavior Driven Development,行為驅動開發,由 Dan North 提出,而它的親爸爸其實是 TDD。

事情是這樣。North 教 TDD 時一直被問同樣幾個問題,這該不該叫 test?要測到多細?先測哪個?

他後來想通了,這些根本不是技術問題,問題出在 test 這個字。

於是 2003 年他寫了 JBehave 取代 JUnit,把整套詞彙從「測試」換成「行為」,2006 年才發表〈Introducing BDD〉這篇經典。

後來受 DDD 的通用語言(Ubiquitous Language)啟發,他和 Chris Matts 把驗收條件的格式定成 Given-When-Then,這才長成今天的樣子。

Dan North 和 Chris Matts 是誰?

Dan North(現名 Daniel Terhorst-North)是英國軟體顧問,寫程式、帶團隊三十幾年,BDD 由他起頭,也提過 Deliberate Discovery 這類概念,現在自己開顧問公司。

Chris Matts 是業務分析師,長年在投資銀行做交易與風險管理系統。他提出 Feature Injection,先找出價值,再回推該做哪些功能,剛好補上 BDD 缺的需求分析那半邊。


所以兩條路的起點不同,ATDD 想解決「怎麼讓客戶參與驗收」,BDD 想解決「test 這個字讓工程師搞不清楚在幹嘛」。

一個往前推到需求端,一個往外推到業務端,最後在 Given-When-Then 會合。(ゝ∀・)b


實務上的差別在於,ATDD 沒有規定驗收條件要寫成什麼樣子,純文字、表格、便利貼都行。而 BDD 主張用統一的語言描述系統行為,也就是前面那套 Given-When-Then。

補充一下,連續出現同一種步驟時,第二句之後可以用 And 接下去,讀起來比較順。想表達反向條件則用 But,兩個都是 Gherkin 官方規格裡的關鍵字。

兩者可以一起用,用 ATDD 的精神開會,用 BDD 的格式記錄。( •̀ ω •́ )✧

總結一下,概念就是:

  • 想到靠人,流程只能提高機率
  • 不忘記靠工具,這件事真的保證得了

不過學術定義是一回事,具體要怎麼做我覺得比較重要,接下來看看可以怎麼實作。

導入 vitest-cucumber ​

回到剛剛那份 .feature 檔,該幫它接上步驟定義,讓它真的跑起來了。

JavaScript 圈最有名的 BDD 工具是 Cucumber,不過官方的 @cucumber/cucumber 有自己一整套 runner 和設定,跟 Vitest 是兩個世界。

既然專案已經在用 Vitest,那就用 vitest-cucumber,它讓 .feature 檔直接驅動 Vitest 測試,不用多養一套測試框架。( •̀ ω •́ )✧

安裝只要一行,也不用改設定檔。

bash
npm install -D @amiceli/vitest-cucumber

接著把 feature 檔實作出來,refund.spec.ts

ts
import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'
import { expect } from 'vitest'
import { createOrder, getRefundedAmount, type Order } from '../order'
import { type RefundResult, submitRefund } from '../refund-service'

const feature = await loadFeature('features/refund.feature')

describeFeature(feature, ({ Rule }) => {
  Rule('累計退款金額不得超過訂單金額', ({ RuleScenario }) => {
    RuleScenario('分次退款直到超出訂單金額', ({ Given, And, When, Then }) => {
      let order: Order
      let result: RefundResult

      Given('顧客有一筆 1000 元的已付款訂單', () => {
        order = createOrder({ total: 1000 })
      })

      And('這筆訂單已經退款過 800 元', () => {
        submitRefund(order, 800)
        expect(getRefundedAmount(order)).toBe(800)
      })

      When('客服再申請退款 400 元', () => {
        result = submitRefund(order, 400)
      })

      Then('系統應該拒絕這筆退款', () => {
        expect(result.accepted).toBe(false)
      })

      And('訂單的累計退款金額應該還是 800 元', () => {
        expect(getRefundedAmount(order)).toBe(800)
      })
    })
  })
})

跑起來一樣紅,抓到一樣的 bug。

修法也不難,把那個一直沒接上的函數接回去就好。

ts
export function checkRefund(order: Order, amount: number): RefundResult {
  if (order.status !== 'paid') {
    return { accepted: false, reason: 'ORDER_NOT_REFUNDABLE' }
  }

  const refundableAmount = order.total - getRefundedAmount(order) 

  if (amount <= 0 || amount > order.total) { 
    return { accepted: false, reason: 'INVALID_AMOUNT' } 
  } 
  if (amount <= 0 || amount > refundableAmount) { 
    return { accepted: false, reason: 'INVALID_AMOUNT' } 
  } 

  return { accepted: true, amount }
}

有趣的是,這行補上去之後,Stryker 才終於有東西可以突變。(´,,•ω•,,)

前端也能用 ​

前端用瀏覽器跑 e2e 測試,同樣能套用這套概念。

Gherkin 描述的是行為,商業規則跟你用什麼技術實作完全無關。

Playwright 這邊的套件叫 playwright-bdd,先把 .feature 編譯成測試檔,再交給 Playwright 原生的 runner 跑。

bash
npm install -D playwright-bdd

設定檔把 defineBddConfig 的回傳值交給 testDir 就好,playwright.config.ts

ts
import { defineConfig } from '@playwright/test'
import { defineBddConfig } from 'playwright-bdd'

const testDir = defineBddConfig({
  features: 'features/**/*.feature',
  steps: 'features/steps/**/*.ts',
})

export default defineConfig({
  testDir,
  use: { baseURL: 'http://localhost:5173' },
})

接著是 UI 版的 Scenario,features/refund-admin.feature

gherkin
Feature: 客服後台退款
  身為 客服人員
  我想要 在後台對訂單分次退款
  以便 處理單件瑕疵的狀況

  Rule: 累計退款金額不得超過訂單金額

    Scenario: 超額退款要在畫面上被擋下來
      Given 客服打開訂單 A001 的退款頁面
      And 這筆訂單金額是 1000 元
      And 這筆訂單已經退款過 800 元
      When 客服輸入退款金額 400 元
      And 客服送出退款
      Then 畫面應該提示可退金額只剩 200 元
      And 退款紀錄應該還是只有一筆

Rule 跟前面那份一模一樣,只有 Scenario 從「申請退款」變成「填欄位、按按鈕」。( •̀ ω •́ )✧

步驟實作在 features/steps/refund-admin.ts

ts
import { expect } from '@playwright/test'
import { createBdd } from 'playwright-bdd'

const { Given, When, Then } = createBdd()

Given('客服打開訂單 {word} 的退款頁面', async ({ page }, orderId: string) => {
  await page.goto(`/admin/orders/${orderId}/refund`)
})

Given('這筆訂單金額是 {int} 元', async ({ page }, total: number) => {
  await expect(page.getByTestId('order-total')).toHaveText(`${total}`)
})

Given('這筆訂單已經退款過 {int} 元', async ({ page }, refundedAmount: number) => {
  await expect(page.getByTestId('refunded-amount')).toHaveText(`${refundedAmount}`)
})

When('客服輸入退款金額 {int} 元', async ({ page }, amount: number) => {
  await page.getByLabel('退款金額').fill(`${amount}`)
})

When('客服送出退款', async ({ page }) => {
  await page.getByRole('button', { name: '送出退款' }).click()
})

Then('畫面應該提示可退金額只剩 {int} 元', async ({ page }, remaining: number) => {
  await expect(page.getByRole('alert')).toHaveText(`可退金額只剩 ${remaining} 元`)
})

Then('退款紀錄應該還是只有一筆', async ({ page }) => {
  await expect(page.getByTestId('refund-row')).toHaveCount(1)
})

先產生測試檔再跑。

bash
npx bddgen && npx playwright test

有個地方要注意,playwright-bdd 走官方 Cucumber.js 路線,步驟全域註冊、用 Cucumber Expression 比對文字,跟 vitest-cucumber 的巢狀 callback 不一樣。

所以 And 開頭的步驟要用 Given 定義,因為 And 會沿用前一句的關鍵字。{word}、{int} 這些參數寫法也是 Cucumber Expression 的標準語法。

Gherkin 到底好在哪 ​

好不好其實也取決於怎麼用、團隊怎麼合作等等,不過 Gherkin 確實有幾項好處。

vitest-cucumber 會拿 .spec.ts 對照 .feature,少實作一個 Scenario、步驟文字對不上、把 When 寫成 Then,通通直接報錯。

規格與測試可以綁在一起,不會有那種文件寫 A、程式做 B 的經典慘案。( •̀ ω •́ )✧

再來看看兩種寫法的差異。

比較項目Vitest 流程測試vitest-cucumber
誰看得懂只有工程師PM 和 QA 都看得懂
規則放哪藏在 it 字串裡,或只在腦袋裡獨立的 .feature 檔
規則會不會過期會,改了實作忘了改描述不會,對不上直接報錯
補一個邊界案例要動程式碼在 Examples 加一行
額外成本無多一層步驟要維護

未來回溯需求與邏輯時,不管是新接手的工程師還是 AI Agent,直接看 .feature 檔會比看測試程式碼更快更清楚。

業務邏輯可讀性較高 ​

講可讀性太抽象,兩種寫法擺在一起就知道了。(´,,•ω•,,)

下面兩份測試測的是同一份退款邏輯,內容完全等價。

先看 Vitest 版。

ts
describe('退款流程', () => {
  let order: Order

  beforeEach(() => {
    order = createOrder({ total: 1000 })
    vi.spyOn(paymentGateway, 'refund').mockResolvedValue({ status: 'ok' })
  })

  it('部分退款後,可退餘額要跟著減少', () => {
    submitRefund(order, 300)

    expect(getRefundedAmount(order)).toBe(300)
    expect(order.refundList).toHaveLength(1)
  })

  it('超額退款要被擋下來', () => {
    submitRefund(order, 800)
    const result = submitRefund(order, 400)

    expect(result.accepted).toBe(false)
    expect(getRefundedAmount(order)).toBe(800)
  })

  it('金流失敗時不應該留下退款紀錄', () => {
    vi.spyOn(paymentGateway, 'refund').mockImplementation(() => {
      throw new Error('gateway down')
    })

    expect(() => submitRefund(order, 300)).toThrow()
    expect(order.refundList).toHaveLength(0)
  })

  it('退滿訂單金額後,訂單要標記為已全額退款', () => {
    submitRefund(order, 1000)

    expect(order.status).toBe('refunded')
  })
})

再看 vitest-cucumber 版,規格與實作分成兩份檔案,先是 refund.feature

gherkin
Feature: 訂單退款
  身為 客服人員
  我想要 對訂單分次退款
  以便 處理單件瑕疵的狀況

  Scenario: 部分退款後,可退餘額要跟著減少
    Given 顧客有一筆 1000 元的已付款訂單
    When 客服申請退款 300 元
    Then 訂單的累計退款金額應該是 300 元
    And 訂單應該有 1 筆退款紀錄

  Scenario: 超額退款要被擋下來
    Given 顧客有一筆 1000 元的已付款訂單
    And 這筆訂單已經退款過 800 元
    When 客服再申請退款 400 元
    Then 系統應該拒絕這筆退款
    And 訂單的累計退款金額應該還是 800 元

  Scenario: 金流失敗時不應該留下退款紀錄
    Given 顧客有一筆 1000 元的已付款訂單
    And 金流服務目前無法連線
    When 客服申請退款 300 元
    Then 系統應該回報退款失敗
    And 訂單不應該有任何退款紀錄

  Scenario: 退滿訂單金額後,訂單要標記為已全額退款
    Given 顧客有一筆 1000 元的已付款訂單
    When 客服申請退款 1000 元
    Then 訂單狀態應該是已全額退款

接著是 refund.spec.ts

ts
const feature = await loadFeature('features/refund.feature')

describeFeature(feature, ({ Scenario }) => {
  Scenario('部分退款後,可退餘額要跟著減少', ({ Given, When, Then, And }) => {
    let order: Order

    Given('顧客有一筆 1000 元的已付款訂單', () => {
      order = createOrder({ total: 1000 })
    })

    When('客服申請退款 300 元', () => {
      submitRefund(order, 300)
    })

    Then('訂單的累計退款金額應該是 300 元', () => {
      expect(getRefundedAmount(order)).toBe(300)
    })

    And('訂單應該有 1 筆退款紀錄', () => {
      expect(order.refundList).toHaveLength(1)
    })
  })

  Scenario('超額退款要被擋下來', ({ Given, And, When, Then }) => {
    let order: Order
    let result: RefundResult

    Given('顧客有一筆 1000 元的已付款訂單', () => {
      order = createOrder({ total: 1000 })
    })

    And('這筆訂單已經退款過 800 元', () => {
      submitRefund(order, 800)
    })

    When('客服再申請退款 400 元', () => {
      result = submitRefund(order, 400)
    })

    Then('系統應該拒絕這筆退款', () => {
      expect(result.accepted).toBe(false)
    })

    And('訂單的累計退款金額應該還是 800 元', () => {
      expect(getRefundedAmount(order)).toBe(800)
    })
  })

  Scenario('金流失敗時不應該留下退款紀錄', ({ Given, And, When, Then }) => {
    let order: Order

    Given('顧客有一筆 1000 元的已付款訂單', () => {
      order = createOrder({ total: 1000 })
    })

    And('金流服務目前無法連線', () => {
      vi.spyOn(paymentGateway, 'refund').mockImplementation(() => {
        throw new Error('gateway down')
      })
    })

    When('客服申請退款 300 元', () => {
      expect(() => submitRefund(order, 300)).toThrow()
    })

    Then('系統應該回報退款失敗', () => {
      expect(order.status).toBe('paid')
    })

    And('訂單不應該有任何退款紀錄', () => {
      expect(order.refundList).toHaveLength(0)
    })
  })

  Scenario('退滿訂單金額後,訂單要標記為已全額退款', ({ Given, When, Then }) => {
    let order: Order

    Given('顧客有一筆 1000 元的已付款訂單', () => {
      order = createOrder({ total: 1000 })
    })

    When('客服申請退款 1000 元', () => {
      submitRefund(order, 1000)
    })

    Then('訂單狀態應該是已全額退款', () => {
      expect(order.status).toBe('refunded')
    })
  })
})

不難看出 Gherkin 版本比較囉唆,但好處是把檢查範圍縮小。

一句話配一條斷言。

Vitest 版的一個 it 塞了兩三條斷言,標題「超額退款要被擋下來」同時涵蓋它們,沒有一條跟標題一對一,你得自己在腦中拆哪句對應哪一半。

Gherkin 版的每個步驟只負責一句話,那句中文就正對著底下唯一那行 expect,對不上就會直接跳出來。

每一行的身分都標好了。

Given 是前置條件、When 是動作、Then 才負責驗證。Vitest 版的佈景和重點混在同一區塊,你得先讀懂程式碼才分得出來,除非你讀程式碼比讀文字還快,那當我沒說。(́⊙◞౪◟⊙‵)

PM 自己就能加測試案例 ​

現在人人都用 AI 寫 Code 了,假設 PM 某天說:「我想確認幾種分次退款的組合對不對。」

以前這句話代表你要開編輯器,現在他自己就能在表格裡加一行。

gherkin
  Rule: 累計退款金額不得超過訂單金額

    Scenario Outline: 各種分次退款的組合
      Given 顧客有一筆 1000 元的已付款訂單
      And 這筆訂單已經退款過 <已退> 元
      When 客服再申請退款 <本次> 元
      Then 退款結果應該是 <結果>

      Examples:
        | 已退 | 本次 | 結果 |
        | 0    | 1000 | 通過 |
        | 800  | 200  | 通過 |
        | 800  | 400  | 拒絕 |
        | 1000 | 1    | 拒絕 |

Scenario Outline 是範本,<已退>、<本次>、<結果> 這種角括號寫法是佔位符,名稱直接對應底下 Examples 表格的欄位。

表格有幾列就跑幾次,每次把那列的值填進去,等於一口氣寫了四條 Scenario。( •̀ ω •́ )✧


實作端把 Scenario 換成 RuleScenarioOutline,callback 第二個參數就是這次跑到的那一列。

ts
RuleScenarioOutline('各種分次退款的組合', ({ Given, And, When, Then }, variables) => {
  // ...

  And('這筆訂單已經退款過 <已退> 元', () => {
    const refundedAmount = Number(variables['已退'])
    if (refundedAmount > 0) {
      submitRefund(order, refundedAmount)
    }
  })

  Then('退款結果應該是 <結果>', () => {
    expect(result.accepted).toBe(variables['結果'] === '通過')
  })
})

variables 是個物件,key 就是 Examples 的欄位名,值一律是字串,所以 已退 要自己 Number() 轉成數字,結果 則是拿 '通過' 這串中文去比對。

那張表格同時是規格、測試案例和驗收清單,三個身分一次滿足。ԅ(´∀` ԅ)

如果只有工程師要看呢? ​

路人:「如果專案只有我一個人,PM 不看、沒有 QA,那不就完全不用導入?(´・ω・`)」

鱈魚:「還是有別的好處啦。(「・ω・)「」


大致上是這樣沒錯,Cucumber 官方講得很直接,TDD 一個人就能做,BDD 的前提是一群人先對話。

不過還是有兩個理由讓我沒完全放棄它。

第一:三個月後的你就是那個熟悉的陌生人。

聽起來很像甚麼言情小說標題,但是這種情況真的不稀奇。(◉◞౪◟◉ )

回去看半年前寫的測試,你會完全想不起來當初為什麼那樣判斷。

.feature 檔記下的是當初定了甚麼規則,而 it 的字串裝不下這些細節。

第二:「不准出現程式碼名詞」這條約束的價值。

被迫用領域語言描述行為,你會一直想「使用者到底想幹嘛」,而不是「這函數該回傳什麼」。

視角換了,看到的東西也會不一樣。◝( •ω• )◟


話說回來,只為了這兩點,成本效益還是有點勉強。

維護這些案例與文件的成本真的很高,看了就累。_(┐「﹃゚。)_

但最近多出來一位新同事,讓我重新評估了這件事。(´,,•ω•,,)

好同事:AI agent ​

沒錯,就是 AI。

以前 BDD 要湊齊三種角色才划算,現在其中一位可能是 agent,而 Gherkin 剛好人和 AI 兩邊都讀得懂。

一、驗收條件被刪會很顯眼 ​

有沒有遇過請 AI 修個 bug,它跑完測試發現紅了,很貼心地把期望值改成實際輸出,回報「已修復,測試全綠」。ლ(・´ェ`・ლ)

驗收條件獨立成檔就沒那麼好動手,少實作一條 Scenario 就報錯,真要拿掉得連那句中文一起刪,而 diff 上少一段商業規則,比少一行 expect 顯眼太多。( ˘•ω•˘ )

不過也就到這裡了,步驟裡的 expect 被註解掉,或是 toBe(false) 換成 toBeDefined(),.feature 檔的內容管不到,也無法限制。(;´༎ຶД༎ຶ`)

這招叫寬鬆斷言(overly general assertion,上一篇有詳細說明),不過突變測試抓的到,所以不是大問題。(ノ>ω<)ノ

剩下的確認與檢查工作就是你的任務了。(ゝ∀・)b

甚麼?你希望檢查和確認也不用做?記得出事的時候不要只會說都是 AI 寫錯。( ・ิω・ิ)

二、feature 檔是最好的需求輸入 ​

Gherkin 的結構是「狀態 → 動作 → 結果」,剛好是 LLM 最擅長處理的形式。

丟一份 feature 檔給 agent 說「把這個實作出來」,比丟一段散文需求精確太多,前提、動作、期望值全都結構化了,沒有模糊空間。(ゝ∀・)b

三、agent 的領域知識索引 ​

agent 每次讀專案都要重新理解商業規則,讀幾千行測試碼很貴,還容易漏。

.feature 檔把散落各處的商業規則集中成十幾個檔案,agent 讀完就掌握了整個領域,token 省得不是一點半點。

四、錨點不會跟著工作一起漂 ​

跑久的 agent 最常見的毛病是飄移,做著做著就忘了原本要幹嘛,最後交出一份很認真但方向錯掉的東西。(´_ゝ`)

意圖只寫在測試程式碼裡,錨點和工作就是同一個檔案,agent 改著改著連錨點一起改掉。

.feature 檔放在外面,改實作時它動都不動,重新對焦也只花一百多個 token。

「系統應該拒絕這筆退款」這句話又沒什麼模糊空間,agent 想自我說服「這樣應該也算完成了」都很難。

甚麼?你的 AI 自我說服成功?請蛋雕那個 AI。⎝(・ω´・⎝)


Scenario 也天生適合當任務邊界,步驟固定、完成條件明確,而飄移多半就是停止條件太模糊。

可是我覺得 Gherkin 很難寫 QQ ​

的確,Gherkin 要寫得好其實不容易,人自己寫都會歪,何況 AI。( ˘•ω•˘ )

常見的歪法有這幾種。

  • 一個 Scenario 塞好幾種行為
  • 用操作步驟取代狀態,寫「點設定、點權限、點下拉選單」,不寫「使用者具有編輯者權限」
  • Then 寫成看不到的結果,例如「登入成功」
  • 資料用 foo、bar,不用實際金額和名字
  • 一個步驟用「並」、「然後」黏兩個動作

好在 Automation Panda 大神已經把整套規則寫成一份 gherkin-guidelines.md,直接丟進專案當 context 檔就好,Claude Code、Cursor、Copilot 都吃得下。( •̀ ω •́ )✧

Automation Panda 是誰?

本名 Andrew Knight,美國測試自動化工程師,長期在 BDD 與 Gherkin 圈子寫作演講,部落格 Automation Panda 累積了大量 Python 測試與 BDD 的實務文章。

但是別急著全部改寫 ​

講了這麼多好話,也要說說壞話,不然像業配。(「・ω・)「

看過不少團隊導入 Cucumber 之後放棄,通常都踩到這四個坑。

一、拿去測工具函數

formatDate、deepClone 這種東西,直接 Vitest 三行寫完,套 Gherkin 只是自找麻煩。BDD 描述的是使用者行為,不是函數簽章。

二、步驟寫成程式碼的翻譯

看過這種 feature 檔嗎?

gherkin
      Given 初始化 RefundService
      When 呼叫 submitRefund 方法並傳入 order 與 amount
      Then 回傳值的 accepted 屬性應該等於 false

這完全是本末倒置。PM 看到只會更困惑,等於付了 Gherkin 的成本,好處一點也沒拿到。ლ(´口`ლ)

判斷標準很簡單,feature 檔裡不應該出現任何程式碼名詞。

三、Then 寫得含糊

gherkin
      Then 退款流程應該正常運作

看起來有驗東西,其實什麼都沒驗,因為「正常運作」沒人能判斷真假。有沒有覺得眼熟?這就是 toBeDefined() 的 Gherkin 版本,規格層的寬鬆斷言。( ˘•ω•˘ )

寫程式時知道要用 toBe(false),寫規格時同一條標準照樣成立,Then 要寫成看得出來的結果。

gherkin
      Then 系統應該拒絕這筆退款   // [!code ++]
      And 訂單的累計退款金額應該還是 800 元   // [!code ++]

而且這種洞比程式碼裡的更貴,因為規格是四關的最上層,它含糊了,底下每一關都跟著含糊。(°ㅂ°)

四、寫完就沒有下文

feature 檔寫完之後沒人看、沒人討論、也沒拿去餵 agent,那就只是把 it 改成 Scenario,ATDD 的精神一點也沒沾到。

判斷方式很簡單,問問自己這份檔案寫給誰看。答案要是「沒有誰」,那就別寫了。( ˘•ω•˘ )


所以務實的做法是分層對待。

層級工具負責回答
單元測試Vitest這個函數寫對了嗎
整合測試Vitest模組串起來還是對的嗎
突變測試Stryker我寫的測試夠不夠嚴格
流程測試vitest-cucumber這條商業規則真的成立嗎
前端 E2E 測試playwright-bdd使用者知道規則守住了嗎

前三層守的是「程式碼有沒有寫錯」,後兩層守的是「我們有沒有想錯」。

各層各司其職,數量從上往下遞減,但越往下,一條測試的價值越高。(ゝ∀・)b

總結 🐟 ​

  • 單元、整合、突變測試都假設規格沒問題,規格少講一條,三關全滅,只有流程測試擋得住。
  • 流程測試難在要寫哪幾條跟規則會不會蒸發。前者只能靠人挖,開會只是提高機率,而工程師往往比 PM 更早撞到規格的洞,撞到就回去問。
  • 後者交給 Gherkin,用人話寫、獨立成檔、跟著測試一起跑,那份人話得是測試的執行來源。同一條 Rule,vitest-cucumber 驗 domain、playwright-bdd 驗畫面。
  • 好處集中在協作,多了 agent 如虎添翼,.feature 檔同時是需求輸入、領域索引與不會漂的錨點。想寫得好,可以參考 gherkin-guidelines.md。

那寫了流程測試之後,是不是就再也不會出包了呢?

當然不是,還有第三方 API 亂噴、時區、閏年、使用者用你想都想不到的方式操作系統⋯⋯不過那又是另一個故事了。乁( ◔ ௰◔)「


路人:「是有多少故事!╭(°A ,°`)╮」


有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。