
呂底亞國王克羅伊索斯,是當時出了名的有錢人,有錢到什麼程度?後來西方甚至直接拿他的名字來形容「富得不得了」,不過克羅伊索斯眼前還得處理一個越來越大的麻煩,東邊的波斯正在居魯士手上快速壯大。
但他一直在想,到底要不要趁現在攻打波斯?通常遇到這種問題,國王大概會找將軍開會、看情報、算兵力,但在那個年代,還有一件很重要的事情要做,去問神。
古希臘世界有很多神諭,其中最有名的,就是德爾菲的阿波羅神諭,這可不是路邊隨便找個人幫你算命,從一般人到城邦,甚至國王,在碰到戰爭、政治這種大事時,都可能特地派人前往德爾菲,希望知道神到底站在哪一邊,德爾菲本身不能命令哪個國家出兵,但它說出來的話,分量可不輕。
於是這次,他送去了大量昂貴的祭品,正式問了問題:
「我要不要攻打波斯?」
神諭給他的回答是:
「如果你向波斯進軍,你將摧毀一個強大的帝國。」
克羅伊索斯一聽開心極了,於是開始找盟友、集結軍隊,帶兵越過哈利斯河,準備把居魯士的新帝國直接打掉。結果打著打著,事情開始不太對勁……克羅伊索斯沒能擊敗居魯士,只好先撤回首都薩第斯,打算整頓軍隊之後再戰。
沒想到居魯士根本不打算給他這個時間,波斯軍一路追到薩第斯,最後攻陷了這座城市,克羅伊索斯也成了階下囚。
直到這個時候,克羅伊索斯才意識到神諭的真正意思:
「你會摧毀一個強大的帝國。」
而克羅伊索斯確實做到了,只是被他摧毀的是自己的國家。
某個活動報名系統已經跑了三年,「活動開始前三天取消,可以全額退費」這個功能也從第一版網站一路沿用到現在,平常大多是零星的個人退票,就算有人在邊界時間取消,客服也常在人工處理中把它消化掉,久了以後,更沒有人特別回頭確認「三天」到底是什麼。
直到星期一早上,客服把一筆退款紀錄丟進群組:
「這筆明明不到三天,為什麼系統判定可以全額退費?」
這不是一張普通的個人票,而是一筆十幾人的團體報名,活動在 9 月 20 日晚上七點開始,這筆報名則在 9 月 17 日晚上十一點取消,兩個時間一攤開,距離活動開始其實剩 68 小時,可是系統已經判定可以全額退費,客服也照著網站顯示回覆客戶,款項隨即進入退款流程,一取消就空出十幾個名額,座位可能來不及重新賣,財務也開始追問這筆錢到底該不該退。
網站上的取消規則至今只確認是:
活動開始前三天取消,可以全額退費。
客服拿著網站文字說「客戶沒有錯」,產品經理卻說「照退款政策應該要完整 72 小時」,QA 接著追問「那剛好卡在 72 小時到底算不算?」
工程師也被 tag 進來了,順手查了一下 Git,作者指向三年前的自己。
「這段是我寫的。」
大家一起看向他……
工程師其實知道系統現在怎麼算,把時間截掉,只拿日期往前減三天,但他已經分不清楚,這是當時確認過的規格,還是自己看完「前三天」就選了一種理解,結果光是「三天」兩個字,四個人腦中就已經出現三種版本。
這段 code 沒有註解、沒有測試,也找不到當初討論的紀錄,直接改成 72 小時,可能讓過去與現在遇到同種情況的客戶得到不同答案,也可能推翻客服已經對客戶說出口的規則,維持原狀,下一筆晚取消又可能照樣全額退費,款項、座位、客服承諾和緊急修改全都開始往前了,團隊卻還沒有共識「正確」到底是什麼。
今天要談的 BDD,我們會先辨認系統現在怎麼做,再透過具體例子確認它接下來應該怎麼做,最後把這份共識變成真的會執行的規格。
前幾天聊到 TDD,今天要談的 BDD 和它同樣關心軟體應該呈現什麼行為,只是 BDD 把問題再往前移了一點,在工程師開始寫失敗測試以前,產品、測試與開發是不是已經對「接下來要完成的行為」有相同理解。
BDD 會被提出,起初是因為 Dan North 在不同團隊使用與教授 TDD 時,反覆遇到一些問題:
第一支測試要從哪裡開始?
該測什麼?
測試要怎麼命名?
後來,他開始試著把注意力從 test 移到 behaviour,與其先想「我要寫什麼測試」,不如先搞清楚「下一個重要的行為是什麼」,這個改變讓許多原本困擾他的 TDD 問題,自然而然有了答案。
2003 年底,Dan North 開始把這套想法具體化,甚至著手開發 JBehave,用一套圍繞「行為」而不是「測試」的語彙來重新描述 TDD。
之後,這個想法又逐漸延伸到商業語言、需求與驗收條件,讓產品、分析、測試與開發能用共同的領域語言,討論「到底怎樣才算完成」。
BDD 的全名是 Behaviour-Driven Development,中文通常翻成「行為驅動開發」,產品、測試與開發會圍繞具體例子討論,先建立對問題與期望行為的共同理解,再讓這些例子一路引導規格、實作與驗證。
這裡的「行為」,指的是系統在某個具體情境下,對使用者產生什麼可觀察結果,而在今天的例子中,我們要談的是:
參加者在活動開始前 68 小時取消時,是否符合全額退費資格?
至於要怎麼修復這個行為,都是確認行為之後才決定的實作細節。
當團隊把共同確認的例子寫成可讀、也能自動執行的規格,它們就不只協助我們開發,之後還能反覆核對系統是否維持相同的行為,也就是說,BDD 同時在處理對話、共同理解、可執行規格與回饋。
Cucumber 官方把日常的 BDD 活動整理成三個不斷循環的實務:

今天我們就沿著這個工作流,從一開始的Discovery 開始,一路走到會執行的規格。
很多時候,我們得要先從 production code 中尋找答案,但這個答案未必是團隊現在確認過的規格,因此,我們先用 Characterization Test 記錄系統目前的行為,再 Discovery 決定接下來要遵守的行為。
工程師從 Git 找到的,就是下面這個判斷,為了聚焦在今天討論的問題,今日範例 省略了其他退款流程的細節:
public sealed class EventRegistrationService
{
public bool IsEligibleForFullRefund(
DateTimeOffset eventStartsAt,
DateTimeOffset cancelledAt)
{
var fullRefundDate = eventStartsAt.Date.AddDays(-3);
return cancelledAt.Date <= fullRefundDate;
}
}
這段 code 會把 9 月 17 日晚上 11 點視為符合資格,因為 .Date 已經把晚上十一點截掉,只剩 9 月 17 日,此時我們還不知道這是不是 Bug,但可以先在測試專案裡加一支 Characterization Test:
public class EventRegistrationServiceTests
{
[Fact]
public void 活動前三個日曆日取消_目前會判定符合全額退費資格()
{
var service = new EventRegistrationService();
var eventStartsAt = new DateTimeOffset(
2026, 9, 20, 19, 0, 0, TimeSpan.FromHours(8));
var cancelledAt = new DateTimeOffset(
2026, 9, 17, 23, 0, 0, TimeSpan.FromHours(8));
var actual = service.IsEligibleForFullRefund(
eventStartsAt,
cancelledAt);
Assert.True(actual);
}
}
這支測試的名字寫著「目前」,而且第一次執行應該是綠燈,它能證明目前系統的答案確實是「符合」,不過要記住並不是證明三年前的理解就是正確規格。
等 Discovery 確認規則後,我們就有證據知道這次改動會推翻什麼現況,以及團隊為什麼要推翻它。
我們先把「活動開始前三天取消,可以全額退費」的需求攤在桌上討論,在 Discovery 階段常見的做法之一是 Example Mapping,用規則、例子與問題先把需求拆開。
| 類型 | 目前內容 |
|---|---|
| Story | 參加者取消報名時,系統判斷是否符合全額退費資格 |
| Rule | 於活動開始前三天取消,符合全額退費資格 |
| Rule | 剛好前三天也符合資格 |
| Example | 9/20 19:00 開始,9/17 18:59 取消:符合資格 |
| Example | 9/20 19:00 開始,9/17 19:00 取消:符合資格 |
| Example | 9/20 19:00 開始,9/17 23:00 取消:不符合資格 |
| Question | 如果活動由主辦方取消,是否套用同一套規則? |
假設產品、QA 與開發最後確認:
討論到這種程度,才有資格把例子寫成可執行規格。
Gherkin 是一套以關鍵字組織結構化自然語言的語法,可以把多個具體情境整理成容易閱讀、也能由工具解析的規格。
在 Gherkin 之前,BDD 就已經有我們很熟悉的 Given/When/Then。
Dan North 在〈Introducing BDD〉中提到,他與 Chris Matts 為了描述 Story 的驗收條件,建立了一套情境模板:
Given:描述事件發生前的已知情境或前置條件。
When:描述發生的事件或行為。
Then:描述事件發生後應該觀察到的結果。
以下簡稱為 GWT。
後來 Gherkin 將這類情境描述進一步整理成正式、可解析的語法,除了 GWT,還有 Feature、Rule、Scenario 等結構。
像 Given_會員存在_When_結帳_Then_套用折扣 這樣的測試案例命名,則是把 GWT 的思考結構套用到測試程式,讓讀者一眼看出「在什麼情況下、發生什麼事情、預期得到什麼結果」。
不過 GWT 不是 BDD,Gherkin 也不是 BDD。
BDD 真正重要的是透過討論去找出具體行為、範例、彼此理解不同的地方,並建立對規則的共同理解,GWT 負責幫助我們整理範例,而 Gherkin 則可以把討論後得到的規則與範例保存成結構化規格。
所以如果把 BDD 理解成把測試改寫成 GWT,那就真的誤會大了,那只是換了句型,還不一定真的在做 BDD。
在開始前,我們先建立一個新的 specs 測試專案:
dotnet new xunit -n specs -f net8.0
dotnet sln Day18.sln add specs/specs.csproj
dotnet add specs/specs.csproj reference src/src.csproj
dotnet add specs/specs.csproj package Microsoft.NET.Test.Sdk --version 17.11.1
dotnet add specs/specs.csproj package xunit --version 2.9.3
dotnet add specs/specs.csproj package xunit.runner.visualstudio --version 2.8.2
這裡我特別指定一些版本以符合兼容性,實際版本還是要依照讀者的環境做選用。
接著在專案建立一個 FullRefundEligibility.feature 檔案如下:
Feature: 活動取消的全額退費資格
為了讓參加者在取消前知道能否全額退費
系統應依距離活動開始的完整時間判斷資格
Background:
Given 活動開始時間為 "2026-09-20 19:00 +08:00"
Rule: 距離活動開始前 72 小時取消,符合全額退費資格
Scenario: 提前超過 72 小時取消
When 參加者在 "2026-09-17 18:59 +08:00" 取消報名
Then 應符合全額退費資格
Scenario: 剛好提前 72 小時取消
When 參加者在 "2026-09-17 19:00 +08:00" 取消報名
Then 應符合全額退費資格
Scenario: 日期看似前三天但不足 72 小時
When 參加者在 "2026-09-17 23:00 +08:00" 取消報名
Then 應不符合全額退費資格
這份 feature 檔就是用 Gherkin 語法書寫的文件,我們來看看這些關鍵字分別代表什麼:
關鍵字替規格提供骨架,後面的句子才是團隊真正要確認的領域語言,這也是範例寫「應符合全額退費資格」,而不是把技術細節或 method 命名也寫進 Feature 的原因。
在過去 .NET 開發者如果講到 Gherkin,很多人第一個想到 SpecFlow,不過 SpecFlow 已經在 2024 年停止維護了,如果現在要在 .NET 專案做 Automation,我們會使用 Reqnroll,它由 SpecFlow 的 codebase 延伸而來,也提供既有 SpecFlow 專案的相容與遷移路線。
我們先透過以下指令進行安裝
dotnet add specs/specs.csproj package Reqnroll.xUnit --version 3.3.4
重新建置後會發現出現一個 FullRefundEligibility.feature.cs檔案,這是因為 Reqnroll 會把 .feature 轉成可由 test runner 執行的物件,看著挺雜亂的,我們可以在 specs.csproj 加上以下設定:
<PropertyGroup>
<ReqnrollUseIntermediateOutputPathForCodeBehind>true</ReqnrollUseIntermediateOutputPathForCodeBehind>
</PropertyGroup>
再重新建置後,就可以把 FullRefundEligibility.feature.cs 刪掉,之後這個設定就會把自動產生的 code 移到 obj,平常不需要閱讀,也不會誤加進 Git。
以下用 Rider 示範,Visual Studio 的使用者可以參考 官方的 Defining Steps 文件,步驟不會差太多。
點開剛才建立的 FullRefundEligibility.feature,上方會出現提示:

點擊 Enable Plugin,在 Marketplace 頁籤中安裝 Reqnroll for Rider,如果沒有看到提示,也可以直接到 Settings/Plugins 搜尋安裝

安裝完成後回到 FullRefundEligibility.feature,未綁定的 Step 下方會出現黃色蚯蚓,左側也會看到黃色燈泡,點下去開啟 Context Actions

選取 Create step

可以將 binding class 命名為 FullRefundEligibilitySteps:

接著替 .feature 裡的所有 Step 建立骨架,就會得到以下程式碼:
[Binding]
public class FullRefundEligibilitySteps
{
[Given("活動開始時間為 {string}")]
public void Given活動開始時間為(string p0)
{
ScenarioContext.StepIsPending();
}
[When("參加者在 {string} 取消報名")]
public void When參加者在取消報名(string p0)
{
ScenarioContext.StepIsPending();
}
[Then("應符合全額退費資格")]
public void Then應符合全額退費資格()
{
ScenarioContext.StepIsPending();
}
[Then("應不符合全額退費資格")]
public void Then應不符合全額退費資格()
{
ScenarioContext.StepIsPending();
}
}
ScenarioContext.StepIsPending() 會把 Step 標記為尚未實作,Rider 目前只產生了能和 Feature 句子配對的 method 骨架,還不知道要呼叫哪個 production、怎麼解析時間,也沒有替我們決定 Assert,因此接下來要來實作它以實現 Automation:
using System.Globalization;
using EventRegistration.Legacy;
using Reqnroll;
using Xunit;
namespace specs;
[Binding]
public class FullRefundEligibilitySteps
{
private readonly EventRegistrationService _service = new();
private DateTimeOffset _eventStartsAt;
private bool? _isEligibleForFullRefund;
[Given("活動開始時間為 {string}")]
public void Given活動開始時間為(string eventStartsAt)
=> _eventStartsAt = ParseDateTime(eventStartsAt);
[When("參加者在 {string} 取消報名")]
public void When參加者在取消報名(string cancelledAt)
=> _isEligibleForFullRefund = _service.IsEligibleForFullRefund(
_eventStartsAt,
ParseDateTime(cancelledAt));
[Then("應符合全額退費資格")]
public void Then應符合全額退費資格()
=> Assert.True(Eligibility);
[Then("應不符合全額退費資格")]
public void Then應不符合全額退費資格()
=> Assert.False(Eligibility);
private bool Eligibility =>
_isEligibleForFullRefund
?? throw new InvalidOperationException("情境尚未判斷全額退費資格。");
private static DateTimeOffset ParseDateTime(string value) =>
DateTimeOffset.ParseExact(
value,
"yyyy-MM-dd HH:mm zzz",
CultureInfo.InvariantCulture);
}
實際執行時,Given 先把 Feature 裡的活動開始時間轉成程式碼看得懂的型別存進 _eventStartsAt,When 再呼叫真正的 IsEligibleForFullRefund() 並保存結果,最後由 Then 使用 Assert 驗證是否符合規格。
回到 FullRefundEligibility.feature,左側會出現執行測試的按鈕

當然也可以直接用指令執行:
dotnet test specs/specs.csproj
執行後會看到有一個規格未通過

點擊失敗項目後,就會自動導航到 feature 檔案

在改成正確行為之前,先把前面記錄現況的 Characterization Test 改寫成 Discovery 後確認的邊界案例,確認兩支測試都先出現紅燈:
[Fact]
public void 活動開始前七十一小時五十九分五十九秒取消_應判定不符合全額退費資格()
{
var service = new EventRegistrationService();
var eventStartsAt = new DateTimeOffset(
2026, 9, 20, 19, 0, 0, TimeSpan.FromHours(8));
var cancelledAt = new DateTimeOffset(
2026, 9, 17, 19, 0, 1, TimeSpan.FromHours(8));
var actual = service.IsEligibleForFullRefund(
eventStartsAt,
cancelledAt);
Assert.False(actual);
}

接著把 production code 改成符合正確規格的行為:
public bool IsEligibleForFullRefund(
DateTimeOffset eventStartsAt,
DateTimeOffset cancelledAt)
{
var noticePeriod = eventStartsAt - cancelledAt;
return noticePeriod >= TimeSpan.FromHours(72);
}
讓測試通過

到這裡,我們不只留下了一份大家都看得懂的規格,也把幾個重要的驗收情境與邊界測試真正連接到 production code 上了,是不是感覺超酷的?
我們剛剛把記錄現況的測試改寫成目標行為,讓它和 Reqnroll Scenario 一起紅燈,再修改 production code 讓兩邊轉為綠燈,做到這裡,我覺得一定有讀者想要開噴了:
「你太閒喔?有好東西怎麼不早說?」
其實是可以的,Gherkin 只是一種替可執行規格建立結構的語法,只要團隊清楚這份 Scenario 記錄的是「目前系統會怎麼做」,它同樣可以拿來做 Characterization Test。
但在規格還沒釐清的階段,我們通常習慣會先用靠近 production 的單元測試,快速確認當前特徵:
68 小時取消,目前確實會被判定為符合全額退費
確認測試真的有呼叫 production,也讓我們知道接下來的修改會推翻什麼,進入 Discovery 後會比想像中來得更有效率。
經過 Discovery 後,答案的來源改變了,BDD Scenario 就會記錄團隊確認過的期望:
不足 72 小時不符合資格
這時候 Gherkin 就會開始發揮價值,把跨角色確認的規則與幾個代表性例子留在同一個地方,而且每次執行都會驗證 production。
其實真的不用糾結到底要不要直接用 Gherkin 先起步,而是我們要找的答案要從哪裡來、要讓誰讀懂,以及團隊準備付出多少維護成本,就以我們要面對的 Legacy Code 有多麻煩來做判斷。
Feature 是個文件又能夠被驗證,第一次接觸 BDD 的讀者確實很容易眼前一亮,不過真正把它放進長期開發流程之前,有些東西我們得先知道,我們體會到了 BDD 工作流帶給我們的快樂,以及對於品質的嚮往後,但我們要保持較真的心態,不能只看好的地方,來看看幾個真正要注意的地方。
當 Feature 與真正的系統行為連結時,並不代表它「不會腐爛」,遇到以下幾種情況,就算是全部綠燈好了,卻也可能早就失去它的功用:
由以上的情境來看的話,導致文件只剩下 Automation 的功能,變成滿足虛榮心的工具了,所以真正讓規格活著的,是團隊在每次遇到需求改變時,仍願意約出來談談。
引入這樣的工作流後,團隊得同時維護多項文件,Reqnroll 的 Binding 在整個 project 內都是 global,Step 範圍寫得太寬,很快就會遇到撞名,或一句話改了卻不知道會影響哪些 Feature,除錯時就會多了一層翻譯,套件、IDE plugin 和產生碼也都要跟著維護。
簡單來說就是 這不是免費的!!!
所以實務上不會把每一支單元測試都寫成對應的 Gherkin,當某條規則需要產品、測試與開發一起確認,而且透過幾個具體、具代表性的例子就能釐清彼此對行為的理解時,Gherkin 就會特別有價值,它的目的不是取代 TDD 過程中所產製的單元測試們,而是幫助團隊建立共同語言,因此也不會和 TDD 產生衝突。
PS: 當然,最終目標還是兩翼齊飛,練到出神入化 XD
所以 BDD 到底是什麼?我覺得真正重要的不是有沒有寫 Gherkin、有沒有 GWT,而是團隊對「接下來系統應該做什麼」是不是有相同的理解?是不是客戶要的?
一份會執行、而且全部綠燈的 Feature,也可能是一份錯的規格,如果 Discovery 一開始就錯了,後面的 Formulation 寫得再漂亮、Automation 跑得再好,就只是把錯誤的理解自動化,我們自己看了很開心產生幻覺而已。
克羅伊索斯拿到的神諭每個字都看得懂,但問題就出在他沒有先問清楚「強大的帝國」指的是哪一個。
而當 Feature 越寫越多,Binding 開始全部擠向同一個無所不能的 Service 時,另一個訊號也會慢慢浮現出來,也許不是測試太難寫,而是這個 class 已經管的太多了,跟神一樣了。
接下來我們繼續看:什麼都管的 God Class,要怎麼識破它的真面目?