iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 18

Day 18 -「德爾菲神諭」BDD?我知道啊!Given When Then 嘛!

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260817/201825641m2gAspwhY.png

呂底亞國王克羅伊索斯,是當時出了名的有錢人,有錢到什麼程度?後來西方甚至直接拿他的名字來形容「富得不得了」,不過克羅伊索斯眼前還得處理一個越來越大的麻煩,東邊的波斯正在居魯士手上快速壯大。

但他一直在想,到底要不要趁現在攻打波斯?通常遇到這種問題,國王大概會找將軍開會、看情報、算兵力,但在那個年代,還有一件很重要的事情要做,去問神。

古希臘世界有很多神諭,其中最有名的,就是德爾菲的阿波羅神諭,這可不是路邊隨便找個人幫你算命,從一般人到城邦,甚至國王,在碰到戰爭、政治這種大事時,都可能特地派人前往德爾菲,希望知道神到底站在哪一邊,德爾菲本身不能命令哪個國家出兵,但它說出來的話,分量可不輕。

於是這次,他送去了大量昂貴的祭品,正式問了問題:

「我要不要攻打波斯?」

神諭給他的回答是:

「如果你向波斯進軍,你將摧毀一個強大的帝國。」

克羅伊索斯一聽開心極了,於是開始找盟友、集結軍隊,帶兵越過哈利斯河,準備把居魯士的新帝國直接打掉。結果打著打著,事情開始不太對勁……克羅伊索斯沒能擊敗居魯士,只好先撤回首都薩第斯,打算整頓軍隊之後再戰。

沒想到居魯士根本不打算給他這個時間,波斯軍一路追到薩第斯,最後攻陷了這座城市,克羅伊索斯也成了階下囚。

直到這個時候,克羅伊索斯才意識到神諭的真正意思:

「你會摧毀一個強大的帝國。」

而克羅伊索斯確實做到了,只是被他摧毀的是自己的國家。


三天總共幾小時?

某個活動報名系統已經跑了三年,「活動開始前三天取消,可以全額退費」這個功能也從第一版網站一路沿用到現在,平常大多是零星的個人退票,就算有人在邊界時間取消,客服也常在人工處理中把它消化掉,久了以後,更沒有人特別回頭確認「三天」到底是什麼。

直到星期一早上,客服把一筆退款紀錄丟進群組:

「這筆明明不到三天,為什麼系統判定可以全額退費?」

這不是一張普通的個人票,而是一筆十幾人的團體報名,活動在 9 月 20 日晚上七點開始,這筆報名則在 9 月 17 日晚上十一點取消,兩個時間一攤開,距離活動開始其實剩 68 小時,可是系統已經判定可以全額退費,客服也照著網站顯示回覆客戶,款項隨即進入退款流程,一取消就空出十幾個名額,座位可能來不及重新賣,財務也開始追問這筆錢到底該不該退。

網站上的取消規則至今只確認是:

活動開始前三天取消,可以全額退費。

客服拿著網站文字說「客戶沒有錯」,產品經理卻說「照退款政策應該要完整 72 小時」,QA 接著追問「那剛好卡在 72 小時到底算不算?」

工程師也被 tag 進來了,順手查了一下 Git,作者指向三年前的自己。

「這段是我寫的。」

大家一起看向他……

工程師其實知道系統現在怎麼算,把時間截掉,只拿日期往前減三天,但他已經分不清楚,這是當時確認過的規格,還是自己看完「前三天」就選了一種理解,結果光是「三天」兩個字,四個人腦中就已經出現三種版本。

這段 code 沒有註解、沒有測試,也找不到當初討論的紀錄,直接改成 72 小時,可能讓過去與現在遇到同種情況的客戶得到不同答案,也可能推翻客服已經對客戶說出口的規則,維持原狀,下一筆晚取消又可能照樣全額退費,款項、座位、客服承諾和緊急修改全都開始往前了,團隊卻還沒有共識「正確」到底是什麼。

今天要談的 BDD,我們會先辨認系統現在怎麼做,再透過具體例子確認它接下來應該怎麼做,最後把這份共識變成真的會執行的規格。


BDD 是什麼?

前幾天聊到 TDD,今天要談的 BDD 和它同樣關心軟體應該呈現什麼行為,只是 BDD 把問題再往前移了一點,在工程師開始寫失敗測試以前,產品、測試與開發是不是已經對「接下來要完成的行為」有相同理解。

BDD 會被提出,起初是因為 Dan North 在不同團隊使用與教授 TDD 時,反覆遇到一些問題:

第一支測試要從哪裡開始?
該測什麼?
測試要怎麼命名?

後來,他開始試著把注意力從 test 移到 behaviour,與其先想「我要寫什麼測試」,不如先搞清楚「下一個重要的行為是什麼」,這個改變讓許多原本困擾他的 TDD 問題,自然而然有了答案。

2003 年底,Dan North 開始把這套想法具體化,甚至著手開發 JBehave,用一套圍繞「行為」而不是「測試」的語彙來重新描述 TDD。

之後,這個想法又逐漸延伸到商業語言、需求與驗收條件,讓產品、分析、測試與開發能用共同的領域語言,討論「到底怎樣才算完成」。

BDD 的全名是 Behaviour-Driven Development,中文通常翻成「行為驅動開發」,產品、測試與開發會圍繞具體例子討論,先建立對問題與期望行為的共同理解,再讓這些例子一路引導規格、實作與驗證。

這裡的「行為」,指的是系統在某個具體情境下,對使用者產生什麼可觀察結果,而在今天的例子中,我們要談的是:

參加者在活動開始前 68 小時取消時,是否符合全額退費資格?

至於要怎麼修復這個行為,都是確認行為之後才決定的實作細節。

當團隊把共同確認的例子寫成可讀、也能自動執行的規格,它們就不只協助我們開發,之後還能反覆核對系統是否維持相同的行為,也就是說,BDD 同時在處理對話、共同理解、可執行規格與回饋。

Cucumber 官方把日常的 BDD 活動整理成三個不斷循環的實務:

https://ithelp.ithome.com.tw/upload/images/20260817/20182564KzdHJDHwCc.png

  • Discovery:產品、開發、測試拿一個即將發生的改動,用具體例子確認規則與未知問題。
  • Formulation:把已確認的例子寫成業務角色也看得懂、工具也讀得懂的規格。
  • Automation:把規格接到真正的系統介面,讓它能反覆執行。

今天我們就沿著這個工作流,從一開始的Discovery 開始,一路走到會執行的規格。


Legacy Code 裡的證據

很多時候,我們得要先從 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:先討論

我們先把「活動開始前三天取消,可以全額退費」的需求攤在桌上討論,在 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 如果活動由主辦方取消,是否套用同一套規則?
  • Story:這次要討論的需求範圍,用一句話描述想交付的業務行為。
  • Rule:Story 必須遵守的規則,這裡把模糊的「前三天」收斂成完整 72 小時,並確認等號算不算。
  • Example:把 Rule 放進具體時間,讓大家直接看見不同理解在邊界前後會得到什麼結果。
  • Question: 會是這次討論所延伸的疑問點,很有可能是討論中很有價值的東西,幫助我們先找到還不能去決定驗證的地方。

假設產品、QA 與開發最後確認:

  1. 「前三天」以活動開始時間往前算完整 72 小時,不是只比較日期。
  2. 剛好 72 小時也符合全額退費資格。
  3. 本次 Story 只處理參加者主動取消,主辦方取消是另一條規則,先留在 Question,不由這次的 code 順便決定。

討論到這種程度,才有資格把例子寫成可執行規格。


Formulation:把共識寫成 Gherkin

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 語法書寫的文件,我們來看看這些關鍵字分別代表什麼:

  • Feature:描述要交付的高層功能,並把相關情境收在同一份檔案中。
  • Background:放所有 Scenario 都需要的共同已知情境,這裡的活動開始時間會在每個 Scenario 前先建立。
  • Rule:描述一條要實作的業務規則,並把用來說明它的 Scenario 放在一起。
  • Scenario:一個有名字的具體業務例子,也是一個可以獨立執行的規格。

關鍵字替規格提供骨架,後面的句子才是團隊真正要確認的領域語言,這也是範例寫「應符合全額退費資格」,而不是把技術細節或 method 命名也寫進 Feature 的原因。


Automation:把 Gherkin 接進 .NET

在過去 .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。

把 Feature 接起來

以下用 Rider 示範,Visual Studio 的使用者可以參考 官方的 Defining Steps 文件,步驟不會差太多。

點開剛才建立的 FullRefundEligibility.feature,上方會出現提示:

https://ithelp.ithome.com.tw/upload/images/20260817/20182564Mkcli5LC5J.png

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

https://ithelp.ithome.com.tw/upload/images/20260817/20182564QZBUib26lw.png

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

https://ithelp.ithome.com.tw/upload/images/20260817/20182564SUF8ayl21J.png

選取 Create step

https://ithelp.ithome.com.tw/upload/images/20260817/20182564tuBXqoFD3P.png

可以將 binding class 命名為 FullRefundEligibilitySteps

https://ithelp.ithome.com.tw/upload/images/20260817/20182564wxgZU0TGC7.png

接著替 .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,左側會出現執行測試的按鈕

https://ithelp.ithome.com.tw/upload/images/20260817/20182564UAmPnAbJL7.png

當然也可以直接用指令執行:

dotnet test specs/specs.csproj

執行後會看到有一個規格未通過

https://ithelp.ithome.com.tw/upload/images/20260817/20182564bVpOHjeEIR.png

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

https://ithelp.ithome.com.tw/upload/images/20260817/20182564RoyLQNXAbN.png

在改成正確行為之前,先把前面記錄現況的 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);
}

https://ithelp.ithome.com.tw/upload/images/20260817/20182564Srtvo1KDsg.png

接著把 production code 改成符合正確規格的行為:

public bool IsEligibleForFullRefund(
    DateTimeOffset eventStartsAt,
    DateTimeOffset cancelledAt)
{
    var noticePeriod = eventStartsAt - cancelledAt;
    return noticePeriod >= TimeSpan.FromHours(72);
}

讓測試通過

https://ithelp.ithome.com.tw/upload/images/20260817/20182564s12Sq8DIaL.png

到這裡,我們不只留下了一份大家都看得懂的規格,也把幾個重要的驗收情境與邊界測試真正連接到 production code 上了,是不是感覺超酷的?


那你幹嘛不一開始就用 Gherkin 做特徵測試?

我們剛剛把記錄現況的測試改寫成目標行為,讓它和 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 與真正的系統行為連結時,並不代表它「不會腐爛」,遇到以下幾種情況,就算是全部綠燈好了,卻也可能早就失去它的功用:

  1. Feature 寫的是業務句子,Binding 卻只操作測試自己的 state
  2. 情境大多從高層級測試進入,執行成本太高,團隊最後放棄維護,只剩文件在角落長蜘蛛網
  3. 每一個邊界情境都塞進同一個 feature,最後只看得到一整排測試資料,真正重要的規則反而被淹沒
  4. Step 為了重用寫得太抽象,業務角色已經看不懂它在說什麼
  5. 規則改了只修 production,團隊沒有重新做 Discovery 與更新文件

由以上的情境來看的話,導致文件只剩下 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,要怎麼識破它的真面目?

Reference


上一篇
Day 17 -「普羅克瑞提斯之床」我明明寫過這個測試啊?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言