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 元 這句。

這種「已經累積了什麼狀態」的前提,在單元測試的思維裡幾乎不存在。

單元測試永遠是「給我一組乾淨的輸入」,而真實世界的 bug 往往就藏在不乾淨的既有狀態裡。( ˘•ω•˘ )

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


這份檔案裡沒有一行程式碼,PM 看得懂、QA 看得懂,連客服都看得懂,而且它可以直接執行,等一下就來兌現這句話。( •̀ ω •́ )✧

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,它讓 Gherkin 直接跑在 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) { 
  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 版的佈景和重點混在同一區塊,你得先讀懂程式碼才分得出來。


這兩點平常沒什麼存在感,等到有人動了你的測試才看得出差別。這件事等一下講 AI 的時候會再回來。( •̀ ω •́ )

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,它跑完測試發現紅了,很貼心地把期望值改成實際輸出,回報「已修復,測試全綠」。ლ(・´ェ`・ლ)

這樣跟考試改答案卡有什麼兩樣。(╬☉д⊙)

.feature 檔對這招有部分抵抗力。少實作一條就報錯,AI 沒辦法只動 .spec.ts 偷偷拿掉驗收條件,非得連 Scenario 一起刪,而 diff 上少一段純中文的商業規則,遠比少一行 expect 顯眼。( ˘•ω•˘ )


路人:「那把步驟裡的 expect 註解掉呢?.feature 檔動都沒動,還是全綠啊。(゚д゚)

鱈魚:「⋯⋯你說得對,這招真的擋不住。(;´༎ຶД༎ຶ`)


所以 Gherkin 的作用是縮小要盯的範圍,AI 掏空哪個步驟,那行就直接對著上面那句中文,格外刺眼。

至於放寬斷言這招,正式名稱叫寬鬆斷言(overly general assertion,上一篇有詳細說明),Stryker 抓得到,toBe(false) 一換成 toBeDefined(),突變體立刻復活。(ノ>ω<)ノ

  • .feature 檔守的是意圖不被偷改
  • 突變測試守的是斷言不被放寬

剩下的確認與檢查工作就是你的任務了。(ゝ∀・)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 寫成看不到的結果,例如「登入成功」
  • 資料用 foobar,不用實際金額和名字
  • 一個步驟用「並」、「然後」黏兩個動作

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

Automation Panda 是誰?

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

但是別急著全部改寫

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

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

一、拿去測工具函數

formatDatedeepClone 這種東西,直接 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 ,°`)╮


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