User Story與BDD串聯體驗

關於Spec

最近在研究關於如何把User Story 跟 BDD開發串聯起來,首先User Story格式是怎麼寫?

設定可參考https://dotblogs.com.tw/bda605/2024/11/13/193529

什麼是使用者故事?

在敏捷開發領域中是一個需求表達方式,用來描述軟體需求,他以一個簡單非技術性需求描述某個場景希望達成的目標。

User Story 的結構

一般來說,User Story 採用以下格式:

作為 一個 [角色],
我希望 [執行某個動作],
以便 [達成某個目標]。

🔹 範例:

作為 一名電子商務網站的顧客,
我希望 能夠將商品加入購物車,
以便 在結帳時一次購買所有選擇的商品。

User Story 的特點

  1. 簡潔易懂 - 讓開發團隊和業務人員都能理解需求。
  2. 以用戶為中心 - 聚焦在用戶需求,而非技術細節。
  3. 可驗證性 - 每個 User Story 應該有明確的驗收標準(Acceptance Criteria),以確保功能達成目標。
  4. 獨立性 - 優秀的 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的程度,最後再把重構和寫好的架構深化直接套用,就可以慢慢體會這樣開發模式。

元哥的筆記