BDD 測試方法論
三個月的重構週期結束後,我們檢視了測試套件,發現一個令人沮喪的問題:每次修改內部實作,即使業務邏輯完全沒變,也需要跟著修改大量測試。一個 Repository 實作替換,導致二十幾個測試需要逐一調整。
這不是測試該有的樣子。問題根源在於測試耦合了實作細節,而非行為。
BDD 的核心定位
BDD 是 TDD 的演進,它要求測試描述系統的「行為」而非「實作」。
行為是使用者視角觀察到的系統反應;實作是程式內部的技術細節。這個區別看起來簡單,實際撰寫測試時卻很容易模糊。
BDD 解決三個問題:
測試維護成本高。傳統單元測試緊密耦合實作細節,重構時即使行為沒變,測試仍需大量修改。BDD 讓重構時測試保持穩定。
需求追溯困難。測試充滿技術細節,無法對應業務需求。Given-When-Then 場景即是需求文件,測試即規格。
溝通成本高。開發、測試和業務人員用不同語言描述系統行為。BDD 統一使用業務語言,建立共通溝通基礎。
我們的分工是:Clean Architecture 定義架構分層,TDD 四階段流程定義開發節奏,BDD 定義測試內容和撰寫規範。
Given-When-Then 結構
Given 描述系統的初始狀態,必須明確完整,只包含與此場景相關的資料。常見錯誤是前置條件模糊,或包含大量無關測試資料。
When 描述使用者執行的操作,必須是單一動作,使用業務語言。「呼叫 Repository 的 save 方法」是技術術語;「使用者提交訂單」是業務語言。一個 When 不能包含多個動作。
Then 描述執行後的狀態變化或結果,必須是可觀察的行為。「Repository 的 save 方法被呼叫一次」是實作細節;「訂單成功儲存並回傳訂單編號」是可觀察的行為。
判斷行為還是實作的方法很簡單:使用者能否觀察到?改變實作會影響這個結果嗎?產品經理需要關心嗎?都是「能觀察、不影響、需要關心」就是行為,反之是實作細節。
行為測試和實作測試的差異
測試實作:
1test('OrderRepository.save should call database.insert', () {
2 repository.save(order);
3 verify(database.insert('orders', order.toJson()));
4});這個測試關注「如何儲存」,替換資料庫或重構儲存邏輯就會失敗。
測試行為:
1test('使用者提交訂單 - 訂單成功儲存', () async {
2 // Given: 使用者已選擇商品並填寫完整資訊
3 final order = validOrder;
4
5 // When: 使用者提交訂單
6 final result = await submitOrderUseCase.execute(order);
7
8 // Then: 系統確認訂單已儲存
9 expect(result.isSuccess, true);
10 expect(result.orderId, isNotEmpty);
11});這個測試關注「訂單是否成功儲存」,重構儲存機制不會影響結果。
測試描述的視角同樣重要。從技術元件角度:
1test('當 Repository 回傳 null 時 UseCase 拋出例外', () { ... });從使用者視角:
1test('使用者提交訂單失敗 - 商品庫存不足', () {
2 // Given: 商品庫存為 0
3 // When: 使用者嘗試提交訂單
4 // Then: 系統回應「庫存不足」錯誤
5});分層測試策略
BDD 不適用所有架構層級,每層特性不同,測試策略也不同。
UseCase 層是 BDD 的核心應用層,代表完整的使用者操作流程,必須使用 Given-When-Then 結構,涵蓋所有業務場景。
Domain 層包含核心業務規則、值物件驗證和實體不變量,需要細緻的邊界條件測試,單元測試更適合。
Behavior 層負責 ViewModel 轉換和事件處理,只有複雜轉換邏輯需要獨立測試,簡單轉換由 UseCase 層覆蓋即可。
UI 層測試成本高,只測試關鍵互動路徑,使用整合測試。
Interface 層只定義契約,沒有實作邏輯,不需要測試。
Mock 策略
核心原則:只 Mock 外層依賴,不 Mock 內層邏輯。
外層依賴(Repository、Service、Event Publisher)透過 Interface 進行 Mock,隔離外部系統。內層邏輯(Domain Entity、Value Object)必須使用真實物件,確保測試涵蓋真實業務邏輯。
正確寫法:
1test('使用者提交訂單成功', () async {
2 // Mock Repository(外層依賴)
3 final mockRepository = MockOrderRepository();
4 when(mockRepository.save(any))
5 .thenAnswer((_) async => SaveResult.success('order-123'));
6
7 // 使用真實的 Domain Entity(內層邏輯)
8 final order = Order(
9 amount: OrderAmount(100),
10 userId: UserId('user-001'),
11 );
12
13 final useCase = SubmitOrderUseCase(repository: mockRepository);
14 final result = await useCase.execute(order);
15
16 expect(result.isSuccess, true);
17 expect(result.orderId, 'order-123');
18});錯誤寫法是 Mock Domain Entity:
1test('使用者提交訂單成功', () {
2 final mockOrder = MockOrder();
3 when(mockOrder.validate()).thenReturn(true);
4 // 沒有測試到任何真實業務邏輯
5});與 TDD 階段整合
階段一(功能設計):從需求識別使用者行為場景。「使用者可以提交訂單」需要提取多個場景:成功提交、庫存不足失敗、金額無效失敗等,每個場景涵蓋正常流程、異常流程和邊界條件。
階段二(測試設計):將行為場景轉換為可執行的測試程式碼,先建立結構,設置 Mock,再依 Given-When-Then 填入邏輯。
階段三(實作策略):測試先行。先完成所有測試場景並確認失敗(Red),才開始實作 UseCase 讓測試通過(Green)。
階段四(重構優化):重構時,行為測試必須保持穩定。重構導致測試需要修改,代表測試耦合了實作。
判斷重構品質的標準很清楚:替換 Repository 實作、改變演算法,不應讓測試失敗;改變業務規則、調整可觀察的錯誤訊息,才應讓測試失敗。
常見挑戰
測試覆蓋率盲點
BDD 強調測試「重要行為」,可能讓某些程式碼未被覆蓋。混合策略解決這個問題:UseCase 層 100% BDD 測試,Domain 層複雜邏輯 100% 單元測試,整體維持 80% 程式碼覆蓋率目標。
學習曲線
從「測試實作」轉向「測試行為」需要思維轉換,初期容易寫出「假行為測試」(實際上還是在測試實作)。建立範例庫和測試模板很有幫助:
1test('[業務場景描述] - 成功', () async {
2 // Given: [前置條件]
3 final input = [準備測試資料];
4 [設置 Mock 行為];
5
6 // When: [觸發動作]
7 final result = await useCase.execute(input);
8
9 // Then: [預期結果]
10 expect(result.isSuccess, true);
11 expect([驗證業務結果]);
12});邊界條件容易被忽略
業務場景描述容易遺漏技術性的邊界條件(null、異常、極端值)。每個 UseCase 最少需要:一個正常流程、兩個異常流程、三個邊界條件。建立技術性測試檢查清單並在 Code Review 重點確認。
測試設置複雜度
UseCase 層的 BDD 測試需要 Mock 多個依賴,建立 Test Helper 和 Builder Pattern 減少重複:
1class UseCaseTestHelper {
2 static MockOrderRepository createMockRepository({
3 required SaveResult saveResult,
4 }) {
5 final mock = MockOrderRepository();
6 when(mock.save(any)).thenAnswer((_) async => saveResult);
7 return mock;
8 }
9}
10
11class OrderBuilder {
12 int _amount = 100;
13 String _userId = 'user-001';
14
15 OrderBuilder withAmount(int amount) {
16 _amount = amount;
17 return this;
18 }
19
20 Order build() => Order(
21 amount: OrderAmount(_amount),
22 userId: UserId(_userId),
23 );
24}行為粒度
粒度太粗,失敗時難以定位;太細則接近單元測試,失去 BDD 優勢。採用「一個 UseCase 等於一個核心行為」的原則:UseCase 代表完整業務流程,名稱以動詞開頭(Submit, Cancel, Query),所有測試場景屬於同一個業務流程。
業務需求變更
需求變更時測試場景仍需更新。集中管理業務規則常數減少影響範圍:
1class OrderBusinessRules {
2 static const int freeShippingThreshold = 1000;
3 static const int maxOrderAmount = 100000;
4 static const int minOrderAmount = 1;
5}完整範例
以「使用者提交訂單」為例:
1group('SubmitOrderUseCase', () {
2 late MockOrderRepository mockRepository;
3 late MockInventoryService mockInventoryService;
4 late MockEventPublisher mockEventPublisher;
5 late SubmitOrderUseCase useCase;
6
7 setUp(() {
8 mockRepository = MockOrderRepository();
9 mockInventoryService = MockInventoryService();
10 mockEventPublisher = MockEventPublisher();
11 useCase = SubmitOrderUseCase(
12 repository: mockRepository,
13 inventoryService: mockInventoryService,
14 eventPublisher: mockEventPublisher,
15 );
16 });
17
18 group('正常流程', () {
19 test('使用者提交訂單成功', () async {
20 // Given: 使用者已選擇商品且填寫完整資訊
21 final order = Order(
22 amount: OrderAmount(100),
23 userId: UserId('user-001'),
24 items: [OrderItem(productId: 'prod-001', quantity: 2)],
25 shippingAddress: Address(city: '台北市', district: '信義區'),
26 );
27 when(mockInventoryService.checkStock('prod-001'))
28 .thenAnswer((_) async => StockStatus.available);
29 when(mockRepository.save(any))
30 .thenAnswer((_) async => SaveResult.success('order-123'));
31
32 // When: 使用者點擊「提交訂單」
33 final result = await useCase.execute(order);
34
35 // Then: 系統確認訂單已儲存並回傳訂單編號
36 expect(result.isSuccess, true);
37 expect(result.orderId, 'order-123');
38 verify(mockEventPublisher.publish(any.having(
39 (e) => e.type, 'event type', EventType.orderCreated,
40 ))).called(1);
41 });
42 });
43
44 group('異常流程', () {
45 test('使用者提交訂單失敗 - 商品庫存不足', () async {
46 // Given: 選擇的商品庫存為 0
47 final order = Order(
48 amount: OrderAmount(100),
49 userId: UserId('user-001'),
50 items: [OrderItem(productId: 'prod-001', quantity: 2)],
51 );
52 when(mockInventoryService.checkStock('prod-001'))
53 .thenAnswer((_) async => StockStatus.outOfStock);
54
55 // When: 使用者點擊「提交訂單」
56 final result = await useCase.execute(order);
57
58 // Then: 系統回應庫存不足錯誤,不儲存訂單
59 expect(result.isSuccess, false);
60 expect(result.error, ErrorType.outOfStock);
61 verifyNever(mockRepository.save(any));
62 });
63
64 test('使用者提交訂單失敗 - Repository 儲存失敗', () async {
65 // Given: Repository 無法儲存(網路錯誤)
66 final order = Order(
67 amount: OrderAmount(100),
68 userId: UserId('user-001'),
69 items: [OrderItem(productId: 'prod-001', quantity: 1)],
70 );
71 when(mockInventoryService.checkStock(any))
72 .thenAnswer((_) async => StockStatus.available);
73 when(mockRepository.save(any))
74 .thenAnswer((_) async => SaveResult.failure('網路連線失敗'));
75
76 // When: 使用者點擊「提交訂單」
77 final result = await useCase.execute(order);
78
79 // Then: 系統回應訂單提交失敗
80 expect(result.isSuccess, false);
81 expect(result.error, ErrorType.saveFailed);
82 });
83 });
84
85 group('邊界條件', () {
86 test('使用者提交訂單失敗 - 訂單金額為 0', () async {
87 final order = Order(
88 amount: OrderAmount(0),
89 userId: UserId('user-001'),
90 items: [],
91 );
92 final result = await useCase.execute(order);
93 expect(result.isSuccess, false);
94 expect(result.error, ErrorType.invalidAmount);
95 });
96
97 test('建立負數金額訂單拋出例外', () {
98 expect(
99 () => Order(amount: OrderAmount(-100), userId: UserId('user-001')),
100 throwsA(isA<InvalidAmountException>()),
101 );
102 });
103
104 test('使用者提交訂單失敗 - 訂單金額超過上限', () async {
105 final order = Order(
106 amount: OrderAmount(1000001),
107 userId: UserId('user-001'),
108 items: [OrderItem(productId: 'prod-001', quantity: 10000)],
109 );
110 final result = await useCase.execute(order);
111 expect(result.isSuccess, false);
112 expect(result.error, ErrorType.amountExceedsLimit);
113 });
114 });
115});結論
回頭看最初那個重構週期,二十幾個因為替換 Repository 實作而失敗的測試,問題很清楚:測試在監視實作細節,而不是守護業務行為。
切換到 BDD 之後,同樣的重構只需確認業務行為沒有改變,測試套件就能保持穩定。
但 BDD 不是萬靈丹。它需要思維轉換,需要建立明確規範,需要持續 Code Review 維持品質。混合策略(UseCase 層 BDD、Domain 層單元測試、UI 層整合測試)才能真正發揮效果。