【TDD】課堂心得與筆記 - Day 1

91's 『自動測試與 TDD 實務開發(使用C#) 第八梯』 第一天課程筆記與心得

一、前言

為什麼我會想來上這堂課?因為我想讓我的程式品質更好、BUG 更少、程式跑起來和需求一致。

91 說明方法與技巧和風格這幾年大幅改變,每字每句都夾帶大量的實務觀念與細節,這是在課程大綱或投影片中看不出來,要實際上過才會了解此課程的深度與廣度。而且課程時數漸漸越來多了,第一天就上好上滿近 9 個小時。因資訊太多,所以寫這篇整理一下,以下內容我盡量使用自己的話描述。也請各位高手多多指教。

 

二、Why - Unit Test

開發人員可能常遇到開發環境無法測試(例:與 JCIC 串接資料)、找 BUG 找不到哪裡錯了、每次修改都要從頭重新(手動)測試、不知道測過什麼、改東壞西、平行測試要等其他人寫完我才能測試... 等。很多人都知道越晚發現 BUG,代表花費成本越高,甚至可能會讓系統延後上線。那為什麼不找解決方案呢?

越早找到問題點,越能降低成本,下圖 Mapping common techniques to the cost of change curve 寫到,寫測試與自動化正是有效降低成本的方法。另外,測試也能解決上述所有問題。一個測試滿足多種願望。

但實務上導入測試時,常聽到「我程式不用測試程式就跑得很好了」、「測試是 QA 的工作」、「時間太趕沒時間寫測試程式」... 等,最大的問題還是在人 (><)。如果公司遇到上述問題且長官支持解決這些痛點,最好的辦法還是找經驗豐富的顧問解決這些問題。遇到痛點,有心想解決問題,導入成功機率才會高一些,才會事半功倍。

回到正題,為什麼要寫 Unit Test?很多人誤以為 Unit Test 就只是測試程式,這只對了一半。確實,沒人希望程式有 BUG,但程式是照你寫的跑,不是照你想的跑。Unit Test 能確保程式寫的和你想的一樣,把你想要做的東西記錄下來。91 在課堂有講到,開發前,Unit Test 是連結『需求』與『production code』間的橋樑,透過測試案例把你想要做的東西記錄下來;開發完成後,Unit Test 是保母角色,細心呵護程式,不讓你的程式出現任何意外。

我個人整理我對 Unit Test 的想法:

  • 理解需求,將需求拆分一個又一個測試個案,思考這些需求預計產生怎樣的結果,先寫下來。
  • 說明需求如何被執行的。
  • 驗證程式碼是否如我理解如我所想的跑。
  • 給開發人員看的程式碼的說明註解,不過前提是要有可讀性。

91 上課有展示這三天下來,完整的實務 TDD 開發(BDD + TDD)後的產出 - 自動化測試,結合以上論述,茲認為優點如下:

  1. 提前釐清需求,確保你想的和你寫的一致。
  2. 快速反饋,提早發現程式中的錯誤。
  3. 強力保護網,讓程式修改更有信心。
  4. 測試及文件。這不只是給開發人員看的文件,也能給 PO / PM 或 SA 看的懂的活文件。

當然,優點不只這些,很多書上也會寫到 TDD 能讓你一次關注一個問題並解決它,讓你保持明快的流程。另外還可以「驅動設計」,讓你的程式除了可測試性外,還能保持彈性與可擴充性,自然而然寫出 SOL(L)ID 風格的程式。

 

三、What - Unit Test

最小的測試單位:

        何謂"最小"?一個 function?一個功能?我們應該從需求面出發,針對一件事情測試,這樣就算是最小的測試了。

外部相依性為零:

        舉凡 DB、網路、檔案 IO、第三方 api、其他人寫的 class... 等,受測目標(System under test, SUT)要遠離這些東西。若沒有隔離這些東西,可能會有以下狀況:

  1. 當測試不通過時,我們會不知道到底是 SUT 錯誤還是這些外部相依物件錯誤。
  2. 測試速度會變慢很多。
  3. 可能會不同環境或多次測試下,發現每次測試結果都不一樣,譬如相依亂數功能,不隔離每一次真的產生亂數要怎麼測呢?
  4. 平行開發時,要等待其他人開發完成才能測試(瓶頸...),且問題發現時間延後,產生更多瓶頸。

不具備商業邏輯

        用商業邏輯來測試商業邏輯?那我們要怎麼知道測試程式中的商業邏輯是對的?再寫一個測試程式測試測試程式中的商業邏輯?這樣會沒完沒了,寫不完阿,進入無窮迴圈。

測試案例間相依性為零:

        舉凡對外或測試案例間的相依,當有相依,就可能測試不通過但你不知道那個環節錯誤。

一個測試案例只測試一件事

        先說何謂一件事?91 上課有說「一件事」是很難定義清楚的。測試一件事不只是只呼叫一個方法,應該要從需求面探討。上課有一張投影片寫到要『驗證加法累計次數的狀態』,所以要先去執行加法功能才能驗證,且加法執行前與執行後都有 Assert 察看狀態變化,呼叫多個方法與多個驗證,這也是測試加法累計次數狀態變化這一件事。驗證狀態變化前後的值是否符合預期,至少會用到兩次驗證,但這也合情合理,這也是驗證一件事。結論是一件事不是指只能呼叫一個方法或只能執行一次 Assert,而是要從驗證的需求(Domain)下去看是否為一件事。

        2017/6/16 新增:何謂一件事?將其比喻成 Unit of Work 是否比較好理解?Unit of Work 精神是將完成一件事情所需的多個動作包成一包,延伸至 unit test,意思是測試一件事內可能有多個動作。到底一件事範圍有多大呢?91 補充單元測試的藝術第二版寫到,單元測試的單位可以小到一個 function, 大到幾個 class 之間的互動。只要 isolated,都是單元測試。

 

四、Unit Test 特性 - FIRST

Fast 快速

        91 課堂上說建議 500 毫秒以內,當你的專案中有上千個測試程式時,整體效能才不會太慢。要做到這樣的速度,一定要讓你的被測程式與外在物件相依性為零。

Independent 獨立

        SUT 無外部相依。隔離的好處如下:

  • 執行速度快
  • 關注點分離
  • 單一職責

Repeatable 重複

        可重複執行且每次執行的結果都相同。這意思是測試程式不依賴運行環境,不會因為正式環境或測試環境導致結果不同。這也意思不依賴日期/時間或亂數,不會因為隨機函數導致不同時間其測試結果不相同。

Self-Validating 自我驗證

  1. 測試程式要有驗證(Assert)功能,確保功能與期待吻合。不需要再手動去其他地方(DB、UI、檔案...)觀察程式狀態是否如你預期的變化。
  2. 當出現紅燈時,能馬上知道錯在哪裡。要達到這個目標,只測試一件事與給測試案例一目了然的命名就變成非常重要。

Timely 及時

        測試程式與 production code 要同時完成。91 課堂上有補充,用 TDD 開發一定能滿足 Timely,但導入測試程式時硬性要求所有開發人員遵從 TDD 規則,會有很大的反彈。底線可改成當簽入版控時,同時交付測試程式與 production code 就好。這個可設定 CI Server,讓工具幫我們確認每次簽入時,測試覆蓋率不要下降即可。測試覆蓋率議題之後會有專章介紹。

 

五、Unit Test 架構 - 淺顯易懂的命名與 3A 原則

就像 MVC 架構一樣,約定優於配置convention over configuration),而測試程式也能利用 3A 架構,讓別人快速的瞭解你的測試程式,也能讓測試程式更好閱讀。

當建立一個 [TestMethod] 時,優先兩件事情,第一件事是為這個測試案例取個好名字,第二件事為測試程式加上 3A 註解(此點沒硬性規定,但初學者建議加上,可以藉由 3A 釐清思路)。

[TestMethod]
public void 淺顯易懂的名稱_用來描述要做什麼_預期產生怎樣的結果()
{
    // Arrange

    // Act

    // Assert

}

測試案例命名

        命名可以使用中文或英文。主要用途有兩個,一個是撰寫測試程式前先思考需求,瞭解我到底要做什麼,預計傳入什麼參數,期望看到怎樣的結果,然後寫在測試方法名稱上面;另一個用途是當出現紅燈時,能快速藉由測試方法名稱快速知道這個測試案例到底是在測試什麼。好的命名可以帶你上天堂,不好的名稱只會讓人不明所以,難怪現在寫程式約花費一半以上想名稱。

Arrange 初始化

        測試前的準備工作

  • 初始化 SUT
  • 初始化方法參數
  • 初始化相依物件(stub 或 mock)

Act 執行

  • 對 SUT 互動,實際測試。
  • 模擬外部使用端對 SUT 互動。

Assert 驗證

        驗證 SUT 是否如預期運作。

開始寫測試程式時,並沒有規定照順序從上往下撰寫,有些人會先寫 Assert,反向驅動出 Act 和 Arrange 的內容。不管是順的寫還是先寫 Assert (目的) 反推 Act 與 Arrange,都是不錯的方法。

 

六、Unit Test Assertion

驗證大概分成三種

  • 回傳值的驗證」:當 SUT 有 return 東西時,不管是數字、字串、物件、集合、例外處理... 等,皆為此項。

  • 狀態的驗證」

  • 外部互動的驗證」:又可稱為行為驗證

備註:以上圖片來源為 91 部落格,並自行重新繪製。

大部分的基本驗證(Assert)與集合驗證(CollectionAssert)可使用 MSTest 內建驗證語法,但 MSTest 有些功能比較普通,這邊推薦幾個驗證時不錯的 NuGet 套件:

  • 比較兩個物件是否相等:
    • ExpectedObjects,詳細請參閱 91 寫的 [Unit Test Tricks] Compare Object Equality。上課中有特別講述兩個物件如何部分比較(keyword : 匿名型別),剛好 91 文章也有講到,請參閱文章中 Partial Compare 段落。
    • FluentAssertions,口語化的驗證寫法,使用 should 為關鍵字,讓你的程式碼更容易閱讀。詳細可參考官方文件
  • 驗證例外處理:FluentAssertions,一樣使用這一套,一樣可以參考官方文件 Exceptions 章節,關鍵字為 ShouldThrow。

另外說明「驗證例外處理」的測試程式。過往寫例外處理,使用 MSTest 框架的大部分的人會使用 [ExpectedException(typeof(xxxException))](不要說你使用 try catch 做驗證,千萬不要這樣做),但這種寫法會有一個問題,你會不知道到底是測試程式寫錯還是 SUT 如預期的產生錯誤,因為這種寫法是偵測整個測試程式內容。比較好的作法是上面寫的使用 ShouldThrow,只針對 SUT 驗證是否如預期產生錯誤訊息。

2017/06/16 新增:兩個物件比較也適用於 DataTable。作法是 DataRow 有 ItemArray 的屬性,可將各欄位轉成 Array,這樣就可以比較。若 actual 為 DataTable,用以下寫法 var actualItemArrayCollection = actual.AsEnumerable().Select(dr => dr.ItemArray); 將 actual 轉成 Array,然後使用 ExpectedObjects 進行比較。

 

七、測試替身 - Stub v.s Mock

SUT 或多或少,很高機率一定會和其他東西相依,即 SUT 有相依其他物件,但是寫測試程式時又希望 SUT 外部相依性為零,這時候就只能靠測試替身隔離相依物件了。

在講測試替身前,先說說要如何隔離相依物件,其方法就是依賴注入(Dependency Injection, DI)。如果是了解物件導向或有經驗的開發人員,或者已經開始寫測試程式的開發人員一定不陌生。我下面記錄上課中,91 針對 Legacy code 如何解耦合,記錄一下步驟。

方法一 - 單一職責(SRP)

  • 不管使用重構或重新設計,想辦法將 SUT 單一職責,不和其他物件相關連,自然就能解耦合了。

方法二 - 依賴注入(DI)

  • 找出 SUT 中相依物件,改成相依於介面。譬如 Calculator calculator = new Calculator(); 改成 ICalculator calculator = new Calculator(); 。當然,要這樣做之前要先建立並實作介面。
    • 從使用者角度定義介面
    • 相依物件實作介面
  • 將相依物件注入至 SUT
    • 從建構式注入 (Constructor Injection)
    • 從 public property 注入 (Setter Injection)
  • 測試程式就能依據這個接口,將測試替身注入 SUT 中,替換相依物件

想要了解 DI,想要學習解耦合技巧,重點在於倚賴抽象(介面)。網路上有很多相關文章,像 91 部落格有一篇文章寫到 91 的團隊開發規範與限制,其中幾條規範就是解決相依問題,條列如下。

  • 一定用 interface 隔開
  • 不允許在 context 中直接初始化 (new instance) 物件
  • 物件相依於 interface, 採依賴注入設計(不一定會使用 DI framework, 但一定有 DI 的設計)

拉回正題,當你隔離相依物件後,測試程式會將測試替身注入至 SUT 中,那到底什麼是測試替身呢?我的理解是測試替身是模擬相依物件行為並於執行測試時替代相依物件,消除所有不確定性因素,讓測試執行階段能針對 SUT 而不受到相依物件干擾。測試替身大概就是下圖的感覺吧。

xUnit Test Patterns: Refactoring Test Code 書中寫到,至少定義了五種測試替身,但一堆測試替身會讓人混亂,且實務上實際常用到的就三種,其中兩種就是 Stub 和 Mock。91 說他在實務運用上 Stub 和 Mock 就佔 99% (或許佔比更高),第三種 Fake 使用於特殊特例上面,以下就不多做介紹了。

那怎麼情況使用 Stub?怎麼情況使用 Mock 呢?當 SUT 相依對象為「回傳值或狀態驗證」時,其測試替身稱為 Stub。當 SUT 相依對象要做「外部互動驗證(又稱為行為驗證)」時,測試替身稱為 Mock。

  • Stub:控制其回傳值,進而在可控情況下影響 SUT,再讓測試程式測試 SUT 是否如預期的回傳值或狀態變化。
  • Mock:驗證這個測試替身是否如預期的和 SUT 互動。通常一個測試最多只有一個 Mock。

在不同使用情境下,測試替身有不同名字外,區分到底是 Stub 還是 Mock 是很重要的。看起來兩者區別很小,但絕對不能誤用,因為 Stub 不會導致測試失敗,但是 Mock 會導致測試失敗,因為 Mock 本身就是 Assertion。意思是使用 Stub 時,測試程式針對 SUT 進行驗證,Stub 只是輔助測試。當使用 Mock 時,驗證 Mock 是否如我們預期和 SUT 互動,所以驗證對象是 Mock。下圖為 Stub 與 Mock 的差異。

備註:以上圖片來源為單元測試的藝術第二版第 4.3 章,並自行重新繪製。

一次只驗證一件事時,不是驗證回傳值,就是驗證狀態改變,不然就是驗證互動,前兩個是 Stub 職責,最後一個是 Mock 職責。基於只測一件事原則,若 Stub 和 Mock 同時出現,你的測試程式會讓人不知道你是針對目標物件驗證還是針對 Mock 驗證,違反一個測試案例只測試一件事原則。91 部落格有一篇文章針對寫 Isolated Unit Test 時常遇到的狀況解析,內容包含『測試程式有太多 Stub』、『幾乎都只使用 Mock』、『Stub 和 Mock 共存議題』逐一說明,這些都是壞味道,這極高的可能是你亂用測試替身,或者是你測試對象太大。詳細就自己點進去 91 部落格自己看看。

課上有特別提到,Mock 維護成本相當的高,基本上使用 Mock 前請三思是否真的要用到。91 也提到實務上 Stub : Mock 大約為 90% : 10% (上課時我還以為這裡是 91 梗 XD),大部分情況都是使用 Stub。

 

八、Mocking Framework - NSubstitute

你還在手刻每一個測試替身嗎?不是不能手刻,而是手刻會有以下問題:

  • 累,花很多時間下去寫,生產力下降。
  • 累,要花很多時間維護,生了小孩是要養的。

因為工程師就是懶,所以已經有人寫好框架可以直接使用。91 課堂上介紹我們使用 NSubstitute,輕量化且簡單使用,能幫助我們快速產生測試替身吧。官網有相當詳細的教學說明

我手寫一份簡單的筆記,翻拍可能有點不清楚,請大家見諒並請多多指教指教。

備註1:圖中的 Fake 也是測試替身的一種,91 課堂上只有提到這個字,91 有說幾乎只有使用 Stub 和 Mock,故筆記只稍微記錄一下功用。

備註2:筆記右上角有寫解耦合大決「Extract and Override」,尤其對 Legacy Code 或 相依底層(譬如 new DateTime(); 這類型的)解耦合超好用。詳細請參考 91 部落格 [Unit Test Tricks] Extract and Override 或 [Unit Test Tricks]Extract and Override Protected Function with Moq 或單元測試的藝術第二版第 3.4 章。91 課堂上使用這招解耦 DateTime.Today,讓測試程式能自由定義想要的日期進行測試。

備註3:mock framework 是指建立測試替身的框架,讓使用者透過框架可以快速建立 Stub 或 Mock。名字一樣但意思不同,可別搞混了。

 

九、測試涵蓋率

是否遇到長官要求測試涵蓋率要達到 78%、87%、或 100%?這問題常在新專案中遇到。或者舊專案本來沒測試,長官要求至少要補到 xx% 以上。個人認為追求這種涵蓋率是沒有意義的,91 也在上課中說到,有一堆方法可以衝高測試涵蓋率,作假還不簡單。但 91 課堂只教真的不教假的,有人遇到長官對測試涵蓋率有不合理要求又無法溝通,請找 91 去你們公司解決長官問題。因為一樣的話,長官比較會聽花錢請來的顧問建議。

測試涵蓋率不足,代表兩個意思:

  • 測試案例不足以涵蓋所有 production code。
  • 存在與需求無關的 production code。

如果長官只看到上面兩句就命令團隊全面提高測試涵蓋率,除非團隊很閒,而且你要團隊成員去擦別人的屁股,程式碼散發又臭又難聞的味道,又不能改壞現有功能,沒人會想去做吃力不討好的事。可能那些散發惡臭的程式就是你主管寫的,幹在心裡說不出口,只能笑笑的加上測試程式。91 有提供簡單的作法,這種作法團隊接受度會比較高,建議如下。

  • 於 CI Server 上,測試涵蓋率初始設定大於 0%,一開始至少有一個測試案例就好,未來的人就不用再開測試專案和拉參考。
  • 童子軍原則:每次簽入程式測試涵蓋率只能持平或上升,不能往下掉。代表每個人只要為自己這次簽入的程式碼負責就好。若簽入測試涵蓋率下降就發警報。
  • 可將補測試程式(含重構)納入 Backlog 中,團隊有空就去補一下。ROI 較高的優先補齊。ROI 高的有 「主要業務邏輯」、「異動頻繁的程式碼(可看版控哪隻常異動)」、和「曾經出錯的程式碼」。
  • 檢視是否有不必要的程式碼,清掃一下垃圾。

91 建議總結就是『相對趨勢大於絕對數字』,每個人從自己開始,為自己這次修改的程式碼負責,然後再慢慢請團隊成員頻繁簽入,持續整合。不要沈迷在測試涵蓋率的數字有多漂亮,我們要思考的是重要程式有沒有被覆蓋?沒被覆蓋到的是發生什麼事了?值得花時間補嗎?還是這段程式碼是垃圾所以沒被覆蓋?我們要看的是測試涵蓋率數字背後所代表的資訊,不要為了測試涵蓋率而測試。

測試涵蓋率可以參考 91 寫的《[Comments] 測試覆蓋率與 TDD 的正確心態

2017/06/18 新增Mutation Testing,此種測試是利用修改 SUT 程式碼來驗證測試程式。當你程式寫完後,隨便修改某處程式碼,至少有一個測試程式紅燈(包含拋出例外),反之,若所有測試程式綠燈,代表你的程式有問題。問題可能是「測試涵蓋率不足」或「測試個案不完全」。可於 Code review 時使用 Mutation Testing 驗證測試程式。

 

十、非 Public 方法的測試方式

非 public 方法是否要直接測試?

不用,因為 private 與 protected 皆會被某個 public 方法呼叫到,藉由測試 public 方法就會測試到 private 與 protected。若 private 與 protected 程式片段沒有被測試涵蓋到,可能為「程式個案代表性不足」或「沒被涵蓋的程式沒有存在的意義」。另外,為了測試 private 與 protected 程式,勢必要轉成 public,為了測試而失去了封裝的意義。

internal 怎麼測試?

於 SUT 專案的 AssemblyInfo.cs 上,加上 [assembly: InternalsVisibleTo("測試專案名稱")] 或 [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")],前者是讓測試程式能夠抓到設定成 internal 的 SUT,後者是要給測試程式看到 mock framework 動態產生的 Stub 和 Mock。

 

十一、總結

Top Down 設計思維

會來上這堂課主要是想學習好讀、好維護、好設計、符合需求的 production code,這是我來上這門課的目地,因為 TDD 能夠幫我達成這樣的目標。聽完第一天課程,明白測試不只是測試,是用測試去描述需求,並透過 TDD 引導出倚賴抽象,隔離實作。所以寫程式的時候能聚焦在切分很小很小的需求(測試案例)上,撰寫出剛好滿足需求的 production code。

寫測試程式是從需求出發,設計與抽象需求,摒棄實作細節,專注於需求層面的抽象層級。到寫 production code 階段,隔離更多的實作細節,聚焦於方法的抽象層級,專注的滿足唯一的測試案例即可。

很喜歡這種從上而下,將需求抽象,再到類別與方法層面的抽象,再一步步到實作階段。

心得

上面多敘述心法部分,因為我認為心法重於工具,所以講到工具的篇幅小很多,甚至直接沒寫 MSTest 部分,因為學習工具最好的作法是多練習,別無他法,我偏向實作卡關後再來 Blog 留個記錄。

最後推薦 91 寫的《[隨筆] 開發人員對 TDD 的心魔》,讓我知道 TDD 的深奧與妙用之處,了解測試不只是測試。你不知道如何設計讓呼叫端易用的 API 嗎?你不知道怎麼簡單設計嗎?你覺得 SOL(L)ID 風格程式碼很難寫嗎?你覺得程式可擴充性差嗎?你很怕改東壞西嗎?你想要有個安全的環境讓你重構程式嗎?TDD 能幫助你,如果你需要的話。每個人都有其目的,而我認為 TDD 能幫助我達成目的,所以學習。如果你有其他方案可以解決你遇到的問題,不一定要學習 TDD。

第一天課程大多都圍繞 Unit Test,重點為如何做到 Isolated Unit Test,從 why 到 what,帶出實務上常用的技巧與實務上常遇到的問題和盲點,光第一天課程內容就花好多天消化,希望沒有消化不良。有寫不好或不清楚或觀念錯誤地方,煩請各位高手指教,謝謝。