單元測試的藝術 - 測試你的程式


前言

為何參加?

受邀於朋友 Alan Syue 的邀請,近期在研究單元測試,一直以來都有聽過,與看業界高手使用過,但聽過看過,跟自己寫過、讀過不一樣,當頭洗下去,才知道箇中奧妙之處。
本身沒有參加這種類型的馬拉松寫作比賽,雖然真實世界馬拉松也不沒參加過,但這次強度沒有鐵人賽 30 天這麼強,但此次參賽算是跨出我的一小步。
以往拖延症,加上吹毛求疵的完美主義,總會想說把所有觀念、資料全部弄懂之後,再去寫文章記錄,「 全部都會 」是個很好的藉口,技術是一場與自己的馬拉松,我會希望這次寫作風格為收斂、精簡,不延伸過多,記錄概要的方式去記錄自己的讀書心得。
如果認知有錯,請不吝留言或計算至 b10130402@gmail.com 請我修正哦!祝看到這篇的各位,程式功力大增!

自己的期望

  1. 完賽 (這點我自己沒把握,但是我想達成的里程碑,拖延症嘛 ~ ) 0723 確診,試試看能不能完成參賽
  2. 在此次寫作松完成後,寫一個簡單的測試 API 代表我知道如何應用這門知識
  3. 透過此次比賽結交更多技術夥伴與不同學習機會,了解自身弱點,加以補強
    那就馬上開始吧!

萬事起頭難,打好基礎最重要

  • SUT(System Under Test) 指的是你要測試的程式對象。
  • 單元測試是指呼叫一個工作單元,從輸入 -> 過程 -> 輸出,確保其輸入至輸出呼叫的程式都有呼叫到,並驗證預期結果(Expect)最終結果(Result)是否一致,其需具備良好特質如下:
    • 可自動化,重複執行
    • 執行結果一致
    • 完全被隔離的,執行時獨立於其他測試
    • 簡單清楚呈現期望為何,發生錯誤原因
  • 整合測試:當 SUT 要用到一個與多個真實相依的物件(真實資料庫、真實第三方、FileServer),就是整合測試。舉例來說:我今天實作一個 Twitch 英雄聯盟排名的遊戲實況,我會去打 Twitch API,等資料要回來,整理成預期輸出格式,再傳回前端。那麼這個實作相依真實第三方 API,如果包含要測試打真實第三方 API,就屬整合測試
  • 每日測試靜思語,應確保下列問題都能在寫測試時回答 Yes
    • 兩週前、兩個月前甚至兩年前寫的測試,今天跑還能正常執行嗎?
    • 每個人執行我的測試,都能得到正常的結果嗎?
    • 測試指令簡單,不會耗費太多時間執行嗎?
  • TDD 測試驅動開發:有別以往先開發,後手動測試,TDD 概念為測試優先開發,撰寫測試時,先想像此程式能正常執行的模樣,例如搜尋關鍵字功能,你必須設想此功能完成時,使用者會如何輸入,去撰寫一個失敗的測試,然後才進行開發,直到測試完成,並快(日)快(以)樂(繼)樂(夜)完成上面的 Loop

第一個單元測試

  • 單元測試實踐方式,其中提到可以站在巨人肩膀上(利用框架)寫測試,我的確想要某一天站在進擊的巨人肩膀上((誤
    以下為特點:
    • 結構化測試:可以繼承基礎類別與介面、標記測試方法特性、利用框架提供驗證方法去驗證程式碼
    • 執行一個、多個測試:可利用 command line 或 GUI 簡易操作,自動執行測試,顯示狀態
    • 確認測試結果
  • 測試命名:簡單好懂,,CamelCase or SnakeCase 皆可,我平常用 CamelCase,但單元測試我用 SnakeCase,可以看討論
    ,書上還有提到測試方法名稱分為三大部分:
    • 測試方法:測試方法、類別,如果是一個方法,例如:轉換小寫至大寫方法,test_convert_lowercase_to_uppercase
    • 情境:測試假設條件,例如:測試小寫至大寫當給小寫英文字情境
      test_convert_lowercase_to_uppercase_when_given_lowercase
    • 預期行為:在測試情境的指定條件下,你對測試方法行為的預期。
      (1) 結果值
      (2) 系統狀態改變
      (3) 呼叫外部第三方提供的服務
      例如:測試小寫至大寫當給數字,需要回傳 Exception,此時命名為 test_convert_lowercase_to_uppercase_when_given_integer_reeturn_exception

測試金三角 3A

  1. Arrange 準備:指的是準備物件、建立物件、一些必要設定。
  2. Act 操作:模擬操作 SUT 去驗證其執行過程,將其 DOC 相依物件 mock 掉。
  3. Assert 驗證:驗證預期與最終結果是否相符,Assert 有許多內建方法,AssertTrue, AssertEqual, AssertSame,像 Equals 代表兩個等於,Same 則是三個等於更嚴格一些,要在自己參照文件去使用。

測試要點

  1. 正負向測試:要考慮到 Sut 正向與負向的輸入值,例如:上傳 csv 檔案的功能,需檢查其副檔名,除了要寫檢查傳入正確副檔名情境,還要去考慮到沒有檔名等其他狀況。
  2. 由紅到綠:紅燈 -> 綠燈 -> 重構,從失敗的測試開始,修改產品程式,讓它通過測試,進一步修改程式碼可讀性與維護性更高。
  3. 風格一致:要駝峰還是底線,統一就好。
  4. 用參數重構程式:將測試寫死的值,用參數替代,重新命名此測試方法,移除相同的測試。
  5. 記得要驗證預期的例外,例如:empty、null 等情境,看是否拋出預期的 Exception。

測試的開始與結束(setup & teardown)

如果你寫過 react,你一定不陌生 component lifecycle,測試方法也有這類特徵,主要就是讓測試執行完成後,就像沒有執行過測試一樣。

  • SetUp:在執行測試方法時,先呼叫 SetUp 方法。
  • TearDown:在測試結束之後,呼叫 TearDown 方法還原狀態、資料或刪除物件。

Stub

Stub 用來解決 dependency 相關問題,因為在測試中,你想測 SUT,但系統中物件與程式產生互動,你無法掌控物件,常見的 external dependency 包含 File System, Memory, Time 等。
此時可以用 Stub 產生一個可控的物件,來取代外部相依物件,避免直接相依外部物件。
舉例來說:測試副檔名正常後,要上傳至檔案系統上,完成此功能,當你依賴於檔案系統上,測試其實不可控,會是整合測試,測試不會快,不可控程度高,因為外部相依很可能導致測試失敗。

因此,解法為將相依的檔案系統替換成你可以控制的程式碼,言下之意,我不呼叫真實的檔案系統,我們將測試方法所呼叫的執行個體(真實檔案系統)替換為一個 Stub (虛設的檔案系統),讓測試程式能控制外部相依物件的行為。

名詞解釋

  1. 重構:這個詞對於新手來說很陌生,寫好的程式好端端的,幹嘛去碰它,萬一這疊床架屋的高樓,因為你的動作,全組壞掉怎麼辦?
    其實,重構的本質就是不改變程式碼功能前提下,修改程式碼,讓其可讀性或是擴充性更佳,因此重構不應該與先前功能有所差異哦!
  2. 接縫:這名詞我第一次看到,我讀完解釋後,我認為是指將程式碼抽換不同功能的地方,利用 constructor 我可以新建實體指定成我要的模樣,也就是說作者鼓勵,將一個方法改成可供覆寫的虛擬方法。此外,建議將程式碼遵循 SOLID 的 開放封閉原則 Open-Closed Principle,讓方法可供外部決定內容。
    其他:SOLID Open-Closed Principle 可參考這篇
    ## 實作
    以下試著以 PHPUnit 這套 PHP 單元測試框架去撰寫。

    我假設一個情境,讓其跟之前的功能串起來,我今天想測試上傳檔案至檔案系統伺服器,在這之前我需要檢查檔名是否包含特殊字元,且規定上傳檔名皆為大寫。

因此,當你使用 Stub 去取代真實的檔案系統,你可以將此檔案系統 Stub 建立類別(Class),包含 uploadServer 這個 method,因為我不想真的上傳真實 Server。

  1. interface
    namespace App\Interfaces;
    interface FileExtensionManagerInterface
    {
     public function uploadServer($file);
    }
    
  2. FileExtensionManager
    class FileManager implements FileExtensionManagerInterface
    {
     public function uploadServer($file)
     {
         // 上傳檔案至 Server
     }
    }
    
    然後,測試不是直接呼叫實體的方法,是呼叫假物件的方法
  3. Test

    class TestFileService extends TestCase
    {
     public function test_file_upload_file_stub_return_true()
     {
         // 建立 Stub 物件
         $stub = $this->createStub(FileManager::class);
    
         // 設定 Stub 物件
         // 這邊會去檢查檔名是否有特殊字元
         // 這邊會去轉換檔名大小寫
         // 上傳檔案至檔案管理系統
         $stub->method('uploadServer')
              ->willReturn(true);
    
         // 只要呼叫 stub 的 doSomething 都會返回 foo
         $this->assertTrue($stub->uploadServer($file);
     }
    }
    

    原來如此,所以我看懂了,因為我要測試的 SUT 為 file 實體的方法,包含檢查檔名是否有特殊字元和轉換檔名大小寫這兩個方法,因此我用一個假的 Stub 檔案系統去取代真實檔案系統,去幫助我更可控的測試我的程式。

    依賴注入
    依賴注入(Dependency Injection),又可簡寫為 DI,在使用方法或是物件時,規範其送進來的參數型態。
    必須小心,當 SUT 依賴越來越多 DI 時,在建構函式會比較難維護,解決方法可能建立一個特殊類別去裝載初始化被測試類別的所有值。

Mock

Mock 想解決的事情為程式與相依物件之間互動時,該如何驗證其過程執行是否無誤,因此驗證包含:

  1. 是否正確呼叫其他物件
  2. 被呼叫幾次
  3. 傳入的參數
  4. 返回的值

業配一下,AlanSyue 參賽文章,裡面有精美的圖,與更詳細的解釋

所以 Mock 與 Stub 差別在於:

  1. Mock 為驗證 SUT 是否如預期呼叫假物件。
  2. Stub 不會導致測試失敗,因為測試是針對 SUT 本身驗證,而非 Stub。

實例

情境:這邊我想再加入通知功能,如果此檔案上傳檔案成功後,呼叫第三方 Api 叫 Slack 通知我檔案上傳成功。

  1. interface
    namespace App\Interfaces;
    interface SlackServiceIntervice
    {
     public function sendMessage(string $id, string $msg);
    }
    
  2. SlackService
    class SlackService implements SlackServiceIntervice
    {
     public function sendMessage(string $id, string $msg)
     {
         // 寄送訊息至指定 slack id 
     }
    }
    
  3. Test
    class TestFileManager extends TestCase
    {
     public function test_file_upload_file_stub_return_true()
     {
         // ...其餘 arrange
         // act 驗證其互動行為
         $mockSlackService = $this->createService(SlackService::class);
         $mockSlackService->expect($this->once())
             ->method('sendMessage')
             ->with('A12345', '上傳成功')
     }
    }
    

後記與感想

這次挑戰看起來是無法在七天閱讀完一本書,很謝謝 AlanSyue 的邀請,測試一直是我很弱的一環,自己要想辦法補起來!
本身不是時間管理大師,又很不幸的,在參賽途中,遇到家人確診,我到假日時,才發病確診,很不想藉口無法完賽,但確實影響到一部分的時間。
我後續會繼續完成閱讀這本書,並試著用一些例子讓自己更好理解實際運用測試的各種情境,避免只是單方面的輸入,沒有輸出驗證。

戰勝拖延症最好的方式,應該就是逼自己把頭洗下去!

#單元測試 #寫作松 #軟體開發 #phpunit
單元測試的藝術, 2/e (The Art of Unit Testing: with examples in C#,  2/e)
單元測試的藝術, 2/e (The Art of Unit Testing: with examples in C#, 2/e)
作者 Roy Osherove 陳仕傑(91) 譯 / 出版社 博碩文化

2017-09-28








你可能感興趣的文章

長的帥,連Code都是香的 - Elvis Operator ?:

長的帥,連Code都是香的 - Elvis Operator ?:

CS50 HTTP (Hypertext Transfer Protocol)

CS50 HTTP (Hypertext Transfer Protocol)

如何使用 Python 設計一個簡單的計算機 入門教學

如何使用 Python 設計一個簡單的計算機 入門教學






留言討論