---
url: /blog-program/bdd-with-vitest-cucumber.md
description: 單元、整合、突變測試全過，怎麼還是出包了？( ´•̥̥̥ ω •̥̥̥` )
---

![bdd-with-vitest-cucumber](/bdd-with-vitest-cucumber.webp){.cover}

# 測試都加了還是出包？是不是少了流程測試？

大家好，我是鱈魚。%( ´ ▽ \` )ﾉ%

上次聊完[突變測試](/blog-program/what-is-mutation-testing-and-stryker)，結尾說系統還是會有流程漏洞，那又是另一個故事。

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

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

結果 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](https://vitest.dev/) 把每個函數測好測滿，邊界一個都不放過。

`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)
  })

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

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

## 第三關：突變測試

看過[上一篇](/blog-program/what-is-mutation-testing-and-stryker)的朋友一定會說，測試全綠不代表什麼，叫 [Stryker](https://stryker-mutator.io/) 出來踢館。

```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?](https://homes.cs.washington.edu/~rjust/publ/mutants_real_faults_fse_2014.pdf)》拿真實專案的 bug 對照突變體，只有 **73%** 對得上。剩下那 27% 裡，很大一塊就是這種「程式碼多了或少了」。

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

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

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

生動一點的比喻就是：

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

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

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

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

三個人都在做對的事，但從頭到尾**沒人想到「分次退款」該怎麼算**。%ლ（´口\`ლ）%

## 那就多寫流程測試？

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

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

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

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

### 難處一：要寫甚麼？

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

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

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

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

路人：「釐清商業規則不是 PM 的事嗎？%(´・ω・\`)%」

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

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

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

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

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

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

至於怎麼讓這種對話變得有結構，BDD 圈有現成的做法，例如 Matt Wynne 的 [Example Mapping](https://cucumber.io/docs/bdd/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](https://agilealliance.org/glossary/three-cs/)。

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

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

| | User Story | Gherkin 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](http://fit.c2.com/)，讓客戶自己寫驗收測試，這條路才真的跑起來。

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

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

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

回頭看看那 1200 元。

> 「如果客服分三次退，每次都退 400 呢？」

路人：「這種案例我自己也想得到啊，開會幹嘛？%(´・ω・\`)%」

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

路人：「...%(´・ω・\`)%」

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

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

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

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

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

### BDD

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

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

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

於是 2003 年他寫了 [JBehave](https://jbehave.org/) 取代 JUnit，把整套詞彙從「測試」換成「行為」，2006 年才發表〈[Introducing BDD](https://dannorth.net/introducing-bdd/)〉這篇經典。

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

::: tip Dan North 和 Chris Matts 是誰？
[Dan North](https://dannorth.net/about/)（現名 Daniel Terhorst-North）是英國軟體顧問，寫程式、帶團隊三十幾年，BDD 由他起頭，也提過 Deliberate Discovery 這類概念，現在自己開顧問公司。

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

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

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

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

補充一下，連續出現同一種步驟時，第二句之後可以用 `And` 接下去，讀起來比較順。想表達反向條件則用 `But`，兩個都是 [Gherkin 官方規格](https://cucumber.io/docs/gherkin/reference/)裡的關鍵字。

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

總結一下，概念就是：

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

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

## 導入 vitest-cucumber

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

JavaScript 圈最有名的 BDD 工具是 [Cucumber](https://cucumber.io/)，不過官方的 `@cucumber/cucumber` 有自己一整套 runner 和設定，跟 Vitest 是兩個世界。

既然專案已經在用 Vitest，那就用 [vitest-cucumber](https://vitest-cucumber.miceli.click/)，它讓 `.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) // [!code ++]

  if (amount <= 0 || amount > order.total) { // [!code --]
    return { accepted: false, reason: 'INVALID_AMOUNT' } // [!code --]
  } // [!code --]
  if (amount <= 0 || amount > refundableAmount) { // [!code ++]
    return { accepted: false, reason: 'INVALID_AMOUNT' } // [!code ++]
  } // [!code ++]

  return { accepted: true, amount }
}
```

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

## 前端也能用

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

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

Playwright 這邊的套件叫 [playwright-bdd](https://vitalets.github.io/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`](https://cucumber.io/docs/gherkin/reference/#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`](https://vitest-cucumber.miceli.click/features/scenario-outline/) 是個物件，key 就是 `Examples` 的欄位名，值一律是**字串**，所以 `已退` 要自己 `Number()` 轉成數字，`結果` 則是拿 `'通過'` 這串中文去比對。

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

## 如果只有工程師要看呢？

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

鱈魚：「還是有別的好處啦。%(｢･ω･)｢%」

大致上是這樣沒錯，[Cucumber 官方](https://cucumber.io/blog/bdd/bdd-vs-tdd/)講得很直接，TDD 一個人就能做，BDD 的前提是一群人先對話。

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

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

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

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

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

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

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

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

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

維護這些案例與文件的成本真的很高，看了就累。%*(┐「﹃ﾟ｡)*%

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

## 好同事：AI agent

沒錯，就是 AI。

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

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

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

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

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

這招叫**寬鬆斷言**（overly general assertion，[上一篇](/blog-program/what-is-mutation-testing-and-stryker)有詳細說明），不過突變測試抓的到，所以不是大問題。%(ﾉ>ω<)ﾉ%

剩下的確認與檢查工作就是你的任務了。%(ゝ∀・)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 大神已經把[整套規則](https://automationpanda.com/2026/04/27/bdd-gherkin-guidelines-for-ai-coding-and-testing/)寫成一份 [gherkin-guidelines.md](https://github.com/AutomationPanda/gherkin-guidelines-for-ai)，直接丟進專案當 context 檔就好，Claude Code、Cursor、Copilot 都吃得下。%( •̀ ω •́ )✧%

::: tip Automation Panda 是誰？
本名 Andrew Knight，美國測試自動化工程師，長期在 BDD 與 Gherkin 圈子寫作演講，部落格 [Automation Panda](https://automationpanda.com/) 累積了大量 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](https://github.com/AutomationPanda/gherkin-guidelines-for-ai)。

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

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

路人：「是有多少故事！%╭(°A ,°\`)╮%」

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