前言
為何參加?
受邀於朋友 Alan Syue 的邀請,近期在研究單元測試,一直以來都有聽過,與看業界高手使用過,但聽過看過,跟自己寫過、讀過不一樣,當頭洗下去,才知道箇中奧妙之處。
本身沒有參加這種類型的馬拉松寫作比賽,雖然真實世界馬拉松也不沒參加過,但這次強度沒有鐵人賽 30 天這麼強,但此次參賽算是跨出我的一小步。
以往拖延症,加上吹毛求疵的完美主義,總會想說把所有觀念、資料全部弄懂之後,再去寫文章記錄,「 全部都會 」是個很好的藉口,技術是一場與自己的馬拉松,我會希望這次寫作風格為收斂、精簡,不延伸過多,記錄概要的方式去記錄自己的讀書心得。
如果認知有錯,請不吝留言或計算至 b10130402@gmail.com 請我修正哦!祝看到這篇的各位,程式功力大增!
自己的期望
- 完賽 (這點我自己沒把握,但是我想達成的里程碑,拖延症嘛 ~ ) 0723 確診,試試看能不能完成參賽
- 在此次寫作松完成後,寫一個簡單的測試 API 代表我知道如何應用這門知識
- 透過此次比賽結交更多技術夥伴與不同學習機會,了解自身弱點,加以補強
那就馬上開始吧!
萬事起頭難,打好基礎最重要
- 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
- Arrange 準備:指的是準備物件、建立物件、一些必要設定。
- Act 操作:模擬操作 SUT 去驗證其執行過程,將其 DOC 相依物件 mock 掉。
- Assert 驗證:驗證預期與最終結果是否相符,Assert 有許多內建方法,
AssertTrue
,AssertEqual
,AssertSame
,像 Equals 代表兩個等於,Same 則是三個等於更嚴格一些,要在自己參照文件去使用。
測試要點
- 正負向測試:要考慮到 Sut 正向與負向的輸入值,例如:上傳 csv 檔案的功能,需檢查其副檔名,除了要寫檢查傳入正確副檔名情境,還要去考慮到沒有檔名等其他狀況。
- 由紅到綠:紅燈 -> 綠燈 -> 重構,從失敗的測試開始,修改產品程式,讓它通過測試,進一步修改程式碼可讀性與維護性更高。
- 風格一致:要駝峰還是底線,統一就好。
- 用參數重構程式:將測試寫死的值,用參數替代,重新命名此測試方法,移除相同的測試。
- 記得要驗證預期的例外,例如:empty、null 等情境,看是否拋出預期的 Exception。
測試的開始與結束(setup & teardown)
如果你寫過 react,你一定不陌生 component lifecycle,測試方法也有這類特徵,主要就是讓測試執行完成後,就像沒有執行過測試一樣。
- SetUp:在執行測試方法時,先呼叫 SetUp 方法。
- TearDown:在測試結束之後,呼叫 TearDown 方法還原狀態、資料或刪除物件。
Stub
Stub 用來解決 dependency 相關問題,因為在測試中,你想測 SUT,但系統中物件與程式產生互動,你無法掌控物件,常見的 external dependency 包含 File System, Memory, Time 等。
此時可以用 Stub 產生一個可控的物件,來取代外部相依物件,避免直接相依外部物件。
舉例來說:測試副檔名正常後,要上傳至檔案系統上,完成此功能,當你依賴於檔案系統上,測試其實不可控,會是整合測試,測試不會快,不可控程度高,因為外部相依很可能導致測試失敗。
因此,解法為將相依的檔案系統替換成你可以控制的程式碼,言下之意,我不呼叫真實的檔案系統,我們將測試方法所呼叫的執行個體(真實檔案系統)替換為一個 Stub (虛設的檔案系統),讓測試程式能控制外部相依物件的行為。
名詞解釋
- 重構:這個詞對於新手來說很陌生,寫好的程式好端端的,幹嘛去碰它,萬一這疊床架屋的高樓,因為你的動作,全組壞掉怎麼辦?
其實,重構的本質就是不改變程式碼功能前提下,修改程式碼,讓其可讀性或是擴充性更佳,因此重構不應該與先前功能有所差異哦! - 接縫:這名詞我第一次看到,我讀完解釋後,我認為是指將程式碼抽換不同功能的地方,利用 constructor 我可以新建實體指定成我要的模樣,也就是說作者鼓勵,將一個方法改成可供覆寫的虛擬方法。此外,建議將程式碼遵循 SOLID 的 開放封閉原則 Open-Closed Principle,讓方法可供外部決定內容。
其他:SOLID Open-Closed Principle 可參考這篇
## 實作
以下試著以 PHPUnit 這套 PHP 單元測試框架去撰寫。我假設一個情境,讓其跟之前的功能串起來,我今天想測試上傳檔案至檔案系統伺服器,在這之前我需要檢查檔名是否包含特殊字元,且規定上傳檔名皆為大寫。
因此,當你使用 Stub 去取代真實的檔案系統,你可以將此檔案系統 Stub 建立類別(Class),包含 uploadServer
這個 method,因為我不想真的上傳真實 Server。
- interface
namespace App\Interfaces; interface FileExtensionManagerInterface { public function uploadServer($file); }
- FileExtensionManager
然後,測試不是直接呼叫實體的方法,是呼叫假物件的方法class FileManager implements FileExtensionManagerInterface { public function uploadServer($file) { // 上傳檔案至 Server } }
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 想解決的事情為程式與相依物件之間互動時,該如何驗證其過程執行是否無誤,因此驗證包含:
- 是否正確呼叫其他物件
- 被呼叫幾次
- 傳入的參數
- 返回的值
業配一下,AlanSyue 參賽文章,裡面有精美的圖,與更詳細的解釋
所以 Mock 與 Stub 差別在於:
- Mock 為驗證 SUT 是否如預期呼叫假物件。
- Stub 不會導致測試失敗,因為測試是針對 SUT 本身驗證,而非 Stub。
實例
情境:這邊我想再加入通知功能,如果此檔案上傳檔案成功後,呼叫第三方 Api 叫 Slack 通知我檔案上傳成功。
- interface
namespace App\Interfaces; interface SlackServiceIntervice { public function sendMessage(string $id, string $msg); }
- SlackService
class SlackService implements SlackServiceIntervice { public function sendMessage(string $id, string $msg) { // 寄送訊息至指定 slack id } }
- 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 的邀請,測試一直是我很弱的一環,自己要想辦法補起來!
本身不是時間管理大師,又很不幸的,在參賽途中,遇到家人確診,我到假日時,才發病確診,很不想藉口無法完賽,但確實影響到一部分的時間。
我後續會繼續完成閱讀這本書,並試著用一些例子讓自己更好理解實際運用測試的各種情境,避免只是單方面的輸入,沒有輸出驗證。
戰勝拖延症最好的方式,應該就是逼自己把頭洗下去!
2017-09-28