背景:
- 重新閱讀了以下 可以引發思考 的 陳年老文
之所以要 “叕” 談 TDD, 除了上述背景,也是因為自己工作4年來,雖然經常聽到 TDD,但著實沒有“完整” 的在項目上實踐過它。直到最近打算在當前的交付項目上實踐,才又重新審視 這項實踐,以求回答下列問題:
在逐一回答這些問題之前,先說我對 TDD 這種實踐的 觀點:
- TDD 是確保 Dev 在編寫代碼時,處于 對需求保持 “清醒(Obvious)” 狀態 的方式之一,但并非 唯一 方式
- TDD 中的測試(T)要面向業務需求,而非代碼實現
- TDD 是一種 快速, 可復用 的 反饋獲取 方式,而非唯一方式
- 如果能 不用 TDD 并做到上述 3 點,那么不 TDD 也沒問題。
如何 TDD
其實 TDD 實踐方式的大體輪廓在上一篇《為何TDD》中的代碼示例已經提及一二,本篇不過是增添細節,將其更加完整、詳盡地展示出來。
簡單來說,TDD 可以有兩種實踐模式:
- 單人模式(Solo)
- 雙人模式(Pair)
它們會給你完全不同的體驗。從整體來看,我將 TDD 的實踐歸納為 3個階段:
- 準備階段
- 實施階段
- 收尾階段
每個階段都有明確的目標和需要掌握的輔助技能,下面我會基于 單人模式 進行詳細介紹。雙人模式如無專門說明,則與單人模式的實踐相同。
準備階段
1. 理解需求
這是一個非常容易遺漏的步驟,因為需求“理應”已經完全標明在故事卡(User Story)中了,只要看完卡片不就好了嗎?
其實不然。
只是讀完卡片,并不能說明需求被完全理解了。在這一步,要做到明白當前卡片的價值何在,同時還需清楚卡片上的驗收條件是否足夠完整驗證卡片價值的達成。在此之后,如果卡片上的驗收條件還不包含了可以使用的具體樣例(數值),而只是抽象的公式或邏輯,那么我們需要將這些抽象的公式或邏輯具象化,形成后續寫測試時可以直接使用的測試數據。
例如 有????的需求描述
作為 話費充值用戶,
我想 在查詢話費時,能夠看到當前賬戶余額,
以便 我能夠了解何時應當進行話費充值
可能的驗收條件
驗收條件1:
給定 一個非欠費用戶,
當 該用戶查詢賬戶余額時,
即 給出當前實際的賬戶金額,賬戶金額應大于等于 0
----------------------------------------
驗收條件2:
給定 一個欠費用戶,
當 該用戶查詢賬戶余額時,
即 給出當前的欠費金額,欠費金額用負數表示,
并 提示友好信息通知用戶充值
在理解需求的過程中,需要具象化上述驗收條件,于是可以發現 友好信息
的內容尚不明確,于是通過進一步溝通和確認,明確了內容:欠費大于 50 元,則停機,友好信息的內容也會有所差別,即
未停機: “您的余額不足,為了避免停機造成的影響,請盡快繳存話費”
已停機: “您當前處于停機中,繳費后恢復服務”
于是,基于邊界條件,可以整理出如下的具象化用例(Exapmle):
對于 驗收條件1:
Example 1:
給定 一個賬戶余額為100的用戶
當 該用戶查詢賬戶余額時,
即 給出當前余額為 100.00
Example 2:
給定 一個賬戶余額為0的用戶
當 該用戶查詢賬戶余額時,
即 給出當前余額為 0.00
--------------------
對于 驗收條件2:
Example 3:
給定 一個欠費10元的用戶,
當 該用戶查詢賬戶余額時,
即 給出當前余額為 -10.00,
并 提示“您的余額不足,為了避免停機造成的影響,請盡快繳存話費”
Example 4:
給定 一個欠費50元的用戶,
當 該用戶查詢賬戶余額時,
即 給出當前余額為 -50.00,
并 提示“您的余額不足,為了避免停機造成的影響,請盡快繳存話費”
Example 5:
給定 一個欠費50.01元的用戶,
當 該用戶查詢賬戶余額時,
即 給出當前余額為 -50.01,
并 提示“您當前處于停機中,繳費后恢復服務”
注: Example 2 和Example 4 用于驗證驗收條件邊界處的滿足與否。
至此,才算需求被理解了。
2. 明確當前系統的測試策略
通常,每個系統都會有自己的架構,而這些架構也都會分成不同的層級,每個層級都會有相應的一些組件。那么在開始TDD 之前,我們一定要先弄清楚針對當前工作的系統架構,它的測試策略是什么?
先弄明白針對目標系統的測試策略,就可以消除TDD 過程中,對于測試粒度不清楚的問題,即我是該寫單元測試呢?還是該寫組件測試?又或者其他什么測試類型?
通常,測試策略很難有定式,需要“因地制宜”,結合測試目的,成本,具體問題具體分析,具體制定,這里就不多作說明了。
基于上述理念,特別強調我們要避免認為TDD 就一定要從單元測試(Unit Test)做起
.
3. 拆分任務
有了一個個具象化的用例,和明確的測試策略,“任務”的目標就很明確了:
將具現化的用例轉化為符合測試策略的測試代碼,并通過測試
但這只是一個非常宏觀的“任務”,它并不是我們在在任務拆分時需要完成的任務。因此,在拆分任務前,我們需要思考任務的應具備的特征:
- 可達成:任務最終是一定能夠完成的
- 可驗收:每個任務的完成與否都有明確的衡量標準
- 可估時間:完成任務所需的時間是可以被大概估計的,可以使用TimeBox追蹤
- 目標相關:完成了這些任務,那么這些任務所對應的目標就能被實現
每一個在時限內完成的任務,都是一次“正向反饋”,它會為開發者提供成就感,從而使開發者進入一種“節奏”,有時通過這種節奏,開發者可以更容易地進入“流”(Flow,一種注意力高度集中的狀態)。
而每一個時限內未能完成的任務,則都是一次“負向反饋”,它為開發者提供反思的入手點,從而歸納總結出可以進一步提升的知識、技能等個人能力。
我通常在 TDD 的實踐中,會將任務拆分到可以在15 - 30 分鐘內完成的大小。如果利用需求理解部分的例子具象化這樣的一個任務,那么在一個傳統的 MVC 分層架構的后端系統中,我的任務拆分結果會是這樣:
任務1: 完成 驗收條件 1 中的功能,通過 Example 1 和 Example 2 的驗證,并通過后端 API 返回期待的結果(20 分鐘)
任務2: 完成 驗收條件 2 中的功能,通過 Example 3, Example 4, Example 5 并通過后端 API 返回期待的結果(30 分鐘)
至此,我們的準備階段就結束了,可以進入接下來的實施階段了。
注:雙人模式
下,準備階段會增加更多的討論,這些討論在一定程度上是有助于探索遺漏的邊界條件,但同時也需要控制討論的效率,求同存異,以防對整體工作造成影響。
實施階段
進入實施階段后,我們就可以帶上一頂名為“實現功能”的帽子,專注業務功能實現,開始代碼編寫了。
在《為何TDD》中,我有貼過一些 TDD 方式產出的代碼。這里,就不再貼出額外的代碼了。但是,可以嘗試利用基于傳統的 MVC 分層架構中的核心業務層(Service),單元測試的作為該層組件的測試策略的場景,總結如下的 TDD 實踐步驟以供參考:
1. 定義目標 Service 類,**簡單設計**類中所需方法的簽名
2. 構造目標 Service 類的測試,根據測試需要,Stub/Mock/Spy/Fake/Dummy(測試替身)當前Service 的依賴項(可以是 Repository 接口,HttpClient 接口,Config接口等)
3. 利用具象化的 Example 中的內容,目標方法的簽名和已聲明的測試替身,編寫測試用例,并運行
4. 調整 Service 類中定義的方法邏輯,通過測試
5. 重復 3,4 步驟,直到之前所有的列出的 Example 都被“翻譯”為測試代碼,并被運行通過
在實施階段的工作完成后,新添加的代碼理應已經可以完全滿足業務需求,并且所有的業務需求,也都已經被“翻譯”為測試代碼。這意味著,無論如何調整代碼,只要已有的測試用例能夠全部通過,當前的業務功能就不會受到任何影響。
那么,我們就可以放心的拿下“實現功能”的帽子,進入最后的收尾階段。
注: 雙人模式
下, 實施階段需要合理分配工作,可以采用工作經驗較豐富或對當前業務更熟悉的一人來“翻譯”測試(編寫測試用例),另一人則專注于通過測試。并在合適的時機進行角色交換,平衡兩人的參與感。更多細節,可以參照《沉思錄---結對編程篇》
收尾階段
在收尾階段中,開發者需要帶上一頂名為“重構”的帽子。此時,有了充足的測試覆蓋的保證,開發人員可以“為所欲為,大刀闊斧,隨心所欲”的使用學到的設計模式,技巧,基于整潔代碼的規范,優化代碼,消除“實現功能”過程中遺留的“壞味道”,使其更易讀、易維護。
總結
如何TDD?
- 理解需求,將需求通過驗收條件,轉化為具象化的Example
- 明確測試策略,結合測試金字塔與測試四象限,設計與測試意圖、成本匹配的測試策略
- 拆分任務,基于需求和任務的特性,對業務需求目標進行拆分
- 利用具象化的Example,測試策略,以“翻譯”需求為目的編寫測試,并以通過測試為目的實現功能
- 通過重構,優化代碼,完成收尾工作