關於Spec
最近在研究關於如何把User Story 跟 BDD開發串聯起來,首先User Story格式是怎麼寫?
設定可參考https://dotblogs.com.tw/bda605/2024/11/13/193529
什麼是使用者故事?
在敏捷開發領域中是一個需求表達方式,用來描述軟體需求,他以一個簡單非技術性需求描述某個場景希望達成的目標。
User Story 的結構
一般來說,User Story 採用以下格式:
作為 一個 [角色],
我希望 [執行某個動作],
以便 [達成某個目標]。
🔹 範例:
作為 一名電子商務網站的顧客,
我希望 能夠將商品加入購物車,
以便 在結帳時一次購買所有選擇的商品。
User Story 的特點
- 簡潔易懂 - 讓開發團隊和業務人員都能理解需求。
- 以用戶為中心 - 聚焦在用戶需求,而非技術細節。
- 可驗證性 - 每個 User Story 應該有明確的驗收標準(Acceptance Criteria),以確保功能達成目標。
- 獨立性 - 優秀的 User Story 應儘量避免與其他故事高度依賴,以提高開發靈活性。
User Story vs. 傳統需求規格
比較項目 | User Story | 傳統需求規格 |
---|---|---|
需求表達方式 | 簡單的語句,貼近業務用戶 | 詳細的技術規格和流程 |
適用場景 | 敏捷開發,迭代式開發 | 傳統瀑布式開發 |
彈性 | 容易修改和調整 | 變更成本較高 |
為什麼要使用 BDD(行為驅動開發)?
BDD(Behavior-Driven Development,行為驅動開發)是一種以業務需求為核心的開發方法,能夠幫助團隊更清晰地理解需求,並確保開發的功能符合業務目標。
BDD 的核心概念
使用者故事(User Stories)
- 定義軟體功能的業務價值,例如:
作為一名註冊使用者,我希望能夠登入系統,以便存取我的帳戶。
Gherkin 語法(Given-When-Then)
- Gherkin 語法是 BDD 測試案例的撰寫格式:
Given
(前置條件):描述系統的初始狀態When
(執行動作):描述使用者的操作Then
(預期結果):描述預期的系統反應
測試框架
- 以 SpecFlow(Reqnroll) 為例,它可以將 Gherkin 定義的案例轉換成可執行的測試。
BDD 的優勢
- 增強跨部門溝通:讓開發、測試與業務團隊擁有共同的理解
- 提高測試可讀性:Gherkin 語法使測試案例更加直觀
- 可重用與可維護性:測試案例與程式碼分離,便於修改
- 自動化測試:搭配工具(如 SpecFlow)可實現端到端的自動化測試
BDD 的缺點
雖然行為驅動開發(BDD)有許多優勢,但在實際應用中也存在一些挑戰和缺點:
1. 學習曲線較高
- 團隊成員(特別是開發人員和測試人員)需要學習 Gherkin 語法和 BDD 工具(如 SpecFlow、Reqnroll、Cucumber),這可能會增加開發初期的學習成本。
2. 測試案例撰寫時間較長
- BDD 需要撰寫詳細的 Gherkin 測試案例,再將其轉換為可執行的測試步驟,這比傳統的單元測試或 API 測試更耗時。
3. 需要團隊高度協作
- BDD 強調業務人員、開發人員和測試人員的協作。如果團隊溝通不順暢,或業務需求變更頻繁,可能導致測試案例難以維護。
4. 測試過於冗長
- 由於 BDD 測試是基於行為描述,某些測試可能比傳統測試更冗長,特別是當測試場景過於細節時,會影響測試的可讀性和執行效率。
5. 測試維護成本高
- 當應用程式的需求變更時,Gherkin 測試案例和步驟實作都需要更新,這可能會增加維護成本,特別是當測試案例數量龐大時。
6. 可能導致過度測試
- BDD 可能會讓團隊過度關注 UI 和 API 測試,而忽略底層的單元測試,導致測試執行時間變長,影響開發效率。
雖然 BDD 有上述缺點,但如果專案符合以下條件,仍然是非常有價值的開發方法:
- 需要業務人員、開發人員和測試人員密切合作
- 需求頻繁變更,需確保測試與需求同步
- 希望自動化測試可讀性高,便於非技術人員理解
如果專案需求相對穩定、開發人員與測試人員已經熟悉 TDD,或者測試主要是技術性測試(如 API、單元測試),可能不適合強制使用 BDD。
以會員登入API為例
# 使用者故事 - 會員登入 API
## User Story 1: 成功登入
**作為** 一個應用程式的使用者,
**我希望** 使用正確的帳號與密碼進行登入,
**以便** 能存取我的帳戶與專屬功能。
### 驗收標準
- 當使用者在登入畫面輸入有效的帳號與密碼後,系統應回傳登入成功。
- 系統應引導使用者進入主畫面。
---
## User Story 2: 登入失敗(帳號或密碼錯誤)
**作為** 一個應用程式的使用者,
**我希望** 當輸入錯誤的帳號或密碼時,系統給予明確錯誤提示,
**以便** 我能確認輸入內容有誤並重新嘗試。
### 驗收標準
- 當使用者輸入無效的帳號或密碼時,系統應回傳並附上「帳號或密碼錯誤」的訊息。
- 使用者仍停留在登入畫面,不進入主畫面。
然後轉換成SPEC格式規格
Feature: 會員登入API
作為一個網站使用者
我希望能夠登入
以便存取我的網站
A short summary of the feature
Scenario: 成功登入
Given 使用者進入登入畫面
When 使用者輸入帳號密碼並進行登入
Then 登入成功
Scenario: 登入失敗 - 帳號或密碼錯誤
Given 使用者進入登入畫面
When 使用者輸入無效帳號密碼並進行登入
Then 帳號或密碼錯誤
並產生規格,並產生規格
using System;
using Reqnroll;
namespace BDDDemo.StepDefinitions
{
[Binding]
public class 會員登入APIStepDefinitions
{
[Given("使用者進入登入畫面")]
public void Given使用者進入登入畫面()
{
throw new PendingStepException();
}
[When("使用者輸入帳號密碼並進行登入")]
public void When使用者輸入帳號密碼並進行登入()
{
throw new PendingStepException();
}
[Then("登入成功")]
public void Then登入成功()
{
throw new PendingStepException();
}
[When("使用者輸入無效帳號密碼並進行登入")]
public void When使用者輸入無效帳號密碼並進行登入()
{
throw new PendingStepException();
}
[Then("帳號或密碼錯誤")]
public void Then帳號或密碼錯誤()
{
throw new PendingStepException();
}
}
}
最後開始設定
public partial class Program { }
安裝Microsoft.AspNetCore.Mvc.Testing;
然後先亮紅燈/先準備好API的Input Output跟Entitie的物件,並開始實作失敗案例規格

public static class MyExtensions
{
/// <summary>
/// 取列舉說明
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static string GetEnumDescription(this Enum value)
{
FieldInfo fi = value.GetType().GetField(value.ToString());
DescriptionAttribute[] attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
//若取不到屬性,則取名稱
if ((attributes != null) && (attributes.Length > 0))
return attributes[0].Description;
else
return value.ToString();
}
}
public enum ResponseCode
{
[Description("成功")]
Success = 0000,
[Description("失敗")]
Fail = 0001,
}
定義API的Input output
public class UserLoginRequest
{
public string UserId { get; set; }
public string Password { get; set; }
}
public class ResponseVM<T>
{
/// <summary>
/// 執行成功與否
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 狀態碼
/// </summary>
public ResponseCode ResponseCode { get; set; }
/// <summary>
/// 錯誤訊息
/// </summary>
public string Message { get; set; }
/// <summary>
/// 資料本體
/// </summary>
public T Data { get; set; }
public ResponseVM<T> Success()
{
ResponseCode = ResponseCode.Success;
IsSuccess = true;
Message = ResponseCode.Success.GetEnumDescription();
return this;
}
public ResponseVM<T> Success(T data)
{
ResponseCode = ResponseCode.Success;
IsSuccess = true;
Data = data;
Message = ResponseCode.Success.GetEnumDescription();
return this;
}
public ResponseVM<T> Fail(T data)
{
ResponseCode = ResponseCode.Fail;
IsSuccess = true;
Data = data;
Message = ResponseCode.Success.GetEnumDescription();
return this;
}
public ResponseVM<T> Fail(ResponseCode responseCode)
{
ResponseCode = responseCode;
IsSuccess = false;
Message = responseCode.GetEnumDescription();
return this;
}
}
using System;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using WebApplication1.Entities;
using WebApplication1.Request;
using WebApplication1.Response;
namespace BDDDemo.StepDefinitions
{
[Binding]
public class 會員登入APIStepDefinitions
{
private readonly HttpClient _client;
private UserLoginRequest _userLoginRequest;
private HttpResponseMessage _response;
public 會員登入APIStepDefinitions()
{
var applicationFactory = new WebApplicationFactory<Program>();
_client = applicationFactory.CreateClient();
}
[Given("使用者進入登入畫面")]
public void Given使用者進入登入畫面()
{
_userLoginRequest = new UserLoginRequest();
}
[When("使用者輸入帳號密碼並進行登入")]
public async Task When使用者輸入帳號密碼並進行登入()
{
_userLoginRequest.UserId = "test001";
_userLoginRequest.Password = "test002";
_response = await _client.PostAsJsonAsync("/api/User/Login", _userLoginRequest);
}
[Then("登入成功")]
public async Task Then登入成功()
{
var actualGoal = await _response.Content.ReadFromJsonAsync<ResponseVM<string>>();
Assert.Equal("登入成功", actualGoal.Data);
}
[When("使用者輸入無效帳號密碼並進行登入")]
public async Task When使用者輸入無效帳號密碼並進行登入()
{
_userLoginRequest.UserId = "test001";
_userLoginRequest.Password = "test001";
_response = await _client.PostAsJsonAsync("/api/User/Login", _userLoginRequest);
}
[Then("帳號或密碼錯誤")]
public async Task Then帳號或密碼錯誤()
{
var actualGoal = await _response.Content.ReadFromJsonAsync<ResponseVM<string>>();
Assert.Equal("帳號或密碼錯誤", actualGoal.Data);
}
}
}
上述應該亮紅燈
來實作程式碼,會亮起紅燈。
Step1:實作成功案例
[Route("api/[controller]/[action]")]
[ApiController]
public class UserController : ControllerBase
{
public List<User> users = new List<User>() {
new User{ UserId = "test001",
Password = "test002",
Name ="Eddie",
Email = "aaa@gmail.com"}
};
[HttpPost]
public ResponseVM<string> Login(UserLoginRequest request)
{
return new ResponseVM<string>().Success("");
}
}
Step2:先讓第一個綠燈通過
[HttpPost]
public ResponseVM<string> Login(UserLoginRequest request)
{
return new ResponseVM<string>().Success("登入成功");
}
接者在實作正確的邏輯和第二個案例
Step3:實作登入的業務邏輯
[HttpPost]
public ResponseVM<string> Login(UserLoginRequest request)
{
var user = users.Where(m => m.UserId == request.UserId && m.Password == request.Password).FirstOrDefault();
if (user != null)
{
return new ResponseVM<string>().Success("登入成功");
}
return new ResponseVM<string>().Fail("");
}
Step4:實作登入失敗邏輯情境
[HttpPost]
public ResponseVM<string> Login(UserLoginRequest request)
{
var user = users.Where(m => m.UserId == request.UserId && m.Password == request.Password).FirstOrDefault();
if (user != null)
{
return new ResponseVM<string>().Success("登入成功");
}
return new ResponseVM<string>().Fail("帳號或密碼錯誤");
}
再跑一次測試

筆者依據這幾年寫的測試程式,有一些體會,慢慢能夠進階到TDD和BDD的程度,最後再把重構和寫好的架構深化直接套用,就可以慢慢體會這樣開發模式。
元哥的筆記