{%hackmd DfWYF9cYREebVNN1eEOz-w %} 單元測試的藝術 Round2 :v: ====== ###### tags: `unittest` `書` `單元測試的藝術` `單元測試的藝術 In Python` `202112` > ***不知自己不知道, 那你會以為你知道.*** 此篇前言 ------ 其它章節: [**round1=> link**](https://hackmd.io/@GoldxTree/Hyi2nVuNt) [round3=> link](https://hackmd.io/@GoldxTree/Sk7ih7fdF) 此篇程式碼範例用跟書中不同語言跟工具做實現, 主要想摸點新玩意:v:, 讓大腦活動活動. 內容多數都會帶入個人觀點去做紀錄. - 環境: - 語言: Python 3.9.7 - 測試工具: pytest-6.2.5 - plugins: mock-3.6.1 # chapter8 好的單元測試三支柱 - 撰寫可信賴的測試 - **測試本身沒有 bug, 而且測試正確的事情** - 撰寫可維護的測試 - **修改測試需花費的時間過多, 時程緊湊時就會被擱置於一旁.** - **修改產品程式碼時, 過於頻繁的修改測試. 導致開發時不想去維護測試.** - 撰寫可讀的測試(==最重要==) - **需求就是對應著測試行為, 無法從測試程式碼導出測試的意圖. 維護測試就會變得困難, 也難以讓其他人信賴.** ## 撰寫可信任的測試 8.1 :::info 1.測試通過了, 你不會說:「還得來逐步偵錯測試程式, 才能確認測試無誤.」 **==測試通過了,== 讓人放心相信產品本身在對應的情境下是肯定沒問題的.** 2.測試失敗了, 你不會說:「產品程式碼沒問題, 應該是測試程式碼有bug.」 **==測試失敗了,== 直覺地相信肯定是產品程式碼出問題了.** ::: **本節介紹的指導原則和技術:** - 決定何時刪除或修改測試 - 避免測試中帶著邏輯 - 一次只測試一個關注點 - 把單元測試與整合測試分開 - 進行程式碼查閱 ### 決定何時刪除或修改測試 8.1.1 #### 什麼情況需修改或刪除測試 - 產品 bug: **這是最好的情況, 代表測試是可信賴的.** - 測試 bug :::info 1. 測試中的 bug 很難發現或不往該方向考慮, 因為測試是依據需求或特定情境來撰寫的. 2. 通常測試有 bug, 會經歷這些階段 拒絕 => 詫異 => 偵錯 => 接受: **拒絕:** 認為測試沒問題, 認為產品有問題而去修改產品程式碼. 導致其它測試也開始失敗或出現另外的 bug. **詫異:** 一時找不到產品程式碼問題點, 導致有點氣餒或心急. **偵錯:** 開始去檢核測試程式碼, 過一陣子才確認是測試出問題. **接受:** 解決這次的問題後, 接受且頓悟了.==(經驗就是這樣累積的)== 3. 修復測試的 bug, 修復後須用以下要點去驗證是否修復成功: **確保測試會失敗:** 調整產品程式碼, 可註解調部分程式碼或者修改回傳值, 讓測試案例失敗. **確保測試會通過:** 將產品程式碼復原後, 確認測試案例可通過. ::: - 產品程式碼邏輯或需求變更 - **矛盾或無效的測試:** 大多是產品後來有新需求, 新的測試與舊需求的測試相互矛盾. 此時先確認問題點後進行測試的調整或刪除. - 需要重新命名或重構測試 - 測試方法的命名不好理解 - 移除不具可讀性的測試方法 - 重複的測試方法: 通常是多個測試類別中, 有類似的測試行為與目的. ### 避免測試中夾帶邏輯 8.1.2 - 把整合測試當成單元測試的誤區 - 包含判斷語句或者迴圈 - **使用多執行緒, 或者亂數產生器:** 將導致測試方法不穩定, 且難以重現某一次的情境(有時成功有時失敗) ### 只測試一個關注點 8.1.3 一個測試方法不要同時驗證回傳值與狀態(兩種行為或目的), 因第一個 assert 失敗後就不會再往後跑, 且可能導致測試報告不好閱讀或資訊不齊全. ### 將程式碼審查並確保測試程式覆蓋率 8.1.4 - 測試只為了做而做, 並沒有去做審查. 簡單來說就是浪費時間. - 審查是為了確保測試品質 - 可以的話, 應由不同的人來進行審查. - 覆蓋率落低於 20%, 說明缺少很多測試. 導致他人來維護專案時改壞程式也不知道. - 審查測試程式碼的要點: - 註解或調整被測試的程式碼, 讓測試失敗. - 執行所有測試 - 如果測試都通過的話, 通常代表測試方法有問題, 亦或者缺少另一個情境的測試 - 在一次執行所有測試, 驗證新增或調整的測試方法. - 將被測試的程式碼復原 - 執行所有測試, 此時測試必須全部通過 ## 撰寫可維護的測試 8.2 ### 測試私有或保護方法 8.2.1 :::info - 私有方法通常是讓部分方法調用, 並不是由需求直接生成的. 因此私有方法無法獨立存在, 必須依附在公開方法底下. ==所以通常測試公開方法等同間接測試私有方法.== - 如果私有方法值得被測試, 那它也許應該是公開或者靜態的. ::: #### 如果私有方法須測試, 可參考以下準則: 1. 將方法改為公開 - 因該方法是某種行為 2. 將方法抽出到新類別 - 特徵點是該方法有使用到欄位, 但該欄位只有該方法使用 3. :sos:**??將方法改為靜態方法??**:sos: - ??這點無法領悟. 因調整成靜態的話, 不會導致無法隔離測試? 所以先打問號? ### 去除重複的程式碼 8.2.2 這裡講的重複, 就是你們所想的到的那種. 包含測試或產品的程式碼. ### 具可維護性測試所用的 setup 方法 8.2.3 #### setup 的使用方法 1. setup 裡面所做的事情, 是所有測試方法需要用到的 2. setup 裡面不要設定與操作假物件. 3. setup 少用. ### 實作隔離測試 8.2.4 :::info - 通常阻礙單元測試的最大阻力, 是沒有做好**隔離** - 隔離的概念是:「不知道其它測試的存在」 ::: #### 嗅出臭味 - 被測試方法中依賴的物件, 沒有進行隔離. 導致測試的不穩定性增加. - 強制的執行順序: 測試時必須依序順序來進行 - 測試方法呼叫其它測試方法 - 共享資源損毀或沒有重製 #### 解決辦法 - 不要在單元測試中, 撰寫流程相關的測試. - 想想沒有隔離, 往後帶來的工作量也是種額外成本. - 使用共享資源時, 記得測試方法必須要包含以下兩點: - 初始化資源 - 釋放資源 ### 避免對相似的關注點進行多次驗證 8.2.5 #### 範例: 相似的關注點進行多次驗證 為了減少撰寫測試的方法數量, 可能會這麼做: ```python=1 def test_checkVariousSumResult_ignoringThan1001(): calculator = calculatorTool() assert 3 == calculator.sum(1, 2, 1001) assert 3 == calculator.sum(1, 1001, 2) assert 3 == calculator.sum(1001, 2, 1) ``` :::info **有需要所有 assert 都呈現嗎?** 就算因第一個 assert 錯了導致其它 assert 沒有呈現, 此時你可能會想這樣寫也可以吧! 把第一個 assert 問題解決後, 在測一次就好啦. 但這會導致測試報告訊息不完整, 再來這些錯誤如果是相關連的呢? 你修復了第一個 assert, 結果接下來的 asserst 還是有問題, ==如果能一次看到所有有問題的 asseert, 很大的可能能加速解決這個問題.== ::: ### 避免過度指定 8.2.7 #### 通常為以下情境 - 對被測試物件的私有狀態進行驗證 - 測試中使用多個模擬物件 - 測試在需要使用虛設常式時, 卻使用模擬物件 #### 對被測試物件的私有狀態進行驗證 ```python= def test_inititalize_whenCalled_setDefaultDelimiter(): logger = LogAnalyzer() assert null == logger.getDefaultDelimiter() # 特別寫一個方法提供內部私有屬性 log.inititalize() # 物件的初始設定 assert '|' == logger.getDefaultDelimiter() """ note: 上述的測試程式碼對私有屬性做驗證, 因此還特別新增了一個方法. 但私有屬性大多容易變動, 通常只需對公開的屬性(狀態)去驗證即可, 因這部分才是跟外部物件相關聯的. """ ``` #### 測試只需要虛設常式時, 卻還另外使用模擬物件 - 情境: 驗證使用者帳號是否存在, 有使用到DB. 模擬 DB 找無帳號時, 會回傳 False. 就會讓登入的流程失敗 - 類別圖: ```mermaid classDiagram IUserRepository <.. LoginManager : dependent <<interface>> IUserRepository IUserRepository: getUserByName(user_id) bool LoginManager: isLoginOK(user_id, password) bool ``` - 虛設常式與模擬物件可參考: [使用模擬物件驗證互動](https://hackmd.io/XDbbWGraQqGY_nP5a6xdkA?view#chapter-4-%E4%BD%BF%E7%94%A8%E6%A8%A1%E6%93%AC%E7%89%A9%E4%BB%B6%E9%A9%97%E8%AD%89%E4%BA%92%E5%8B%95) ```python= def test_isLoginOk_userDoesNotExit_returnFalse(mocker): # 虛設常式 && 模擬物件 都用 fake_repo = mocker.patch('DB_service.IUserRepository') mocker_method = mocker.patch.object(fake_repo, 'getUserByName', return_value=False) # 方法設定回傳值 loginService = LoginManager(fake_reop) result = loginService.isLoginOK('UserThatDoesNotExist', 'passsword') assert False == result # 驗證了被測試物件的回傳值 assert True == mocker_method.called # 驗證了與DB的互動 ``` :::info **說明** :mag_right: 以這個測試情境來說, 去驗證被測試的物件回傳值就夠了! ==為什麼那麼說? 因為可能輸入空白時, 就不去呼叫 DB 了.== 一個測試方法去驗證回傳值與互動, 會導致測試失焦. **回傳值與互動的驗證在一個測試方法裡面最好只有一個.** 若以上的例子, 拆成兩個測試方法分別驗證. 因為測試的穩定性增加, 是較好維護的寫法. ::: ## 撰寫可讀性高的測試 8.3 - 不可讀的測試幾乎沒有任何意義, 可讀性讓「撰寫測試的人」與「日後維護該專案的人」易於連結. - 測試是專案的說明書並協助描述相關故事, 幫助開發人員了解應用程式的組成與功能的始末. - 可讀性可分成下列幾點: - 命名單元測試 - 命名變數 - 使用好的驗證資訊 - 把驗證和操作分開 ### 單元測試的命名 8.3.1 #### 命名標準非常重要, 提供合理的規則和樣板來讓團隊依循著. 測試方法的命名組成: $$ 被測試方法名稱__測試情境__預期行為 $$ - 被測試方法名稱: 明確指出被測試邏輯的位置 - 測試情境: 說明了測試使用的條件「我用一個 null 參數呼叫方法 filenameValid」 - 預期行為: 基於情境會有什麼結果 「我用一個 null 參數呼叫方法 filenameValid,**那麼 write 方法必須被執行**」 - 綜觀上述三點的測試方法名稱應為: $$filenameValid__whenFilenameIsNull__writeCalled$$ ### 變數明確的命名 8.3.2 #### 可讀性差的範例 ```python=1 def test_getLineCount_filenameErr_returnNegative4(): ... log = LogAnalyzer(fakeObject) result = log.getLineCount('abc.') assert -4 == result ``` 如果直接看程式碼, 不好理解 -4 代表什麼? 可參考以下的方法來替換: 1. 修改 getLineCount 方法, 不是回傳 -4 而是丟出例外 2. 給預期結果一個好的名稱 #### 依據上述第二點, 可讀性較佳的釋例: ```python=1 def test_getLineCount_filenameErr_returnNegative4(): ... log = LogAnalyzer(fakeObject) result = log.getLineCount('abc.') filename_is_error_format_returnCode = -4 assert filename_is_error_format_returnCode == result ``` ### 涵蓋 8.3.3~8.3.5 #### 撰寫測試資訊時的要點 8.3.3 - 如果可的話提供發生的時間點(這點通常測試工具都會有) - 如果沒什麼有用得資訊, 就什麼也不要說 #### 驗證和操作分離 8.3.4 不好的範例: ```python=1 def test_badAssertMessage(): ... assert could_not_read_file == log.getLineCount('abc.txt') ``` 調整成以下, 有沒有比較好理解呢? ```python=1 def test_badAssertMessage(): ... result = log.getLineCount('abc.txt') assert could_not_read_file == result ``` #### setup 和 teardown 8.3.5 - 單元測試中這兩個方法很常被濫用, 通常 setup 濫用情況較為嚴重 - 單元測試中的同一個類別中, 每個方法的關聯性是越少越好. - 有每個方法都需要用到的實際物件, 在使用這兩個方法. - 如果再 setup 初始化了物件, 要注意在 teardown 要把物件復原或釋放 ## 小結 - 優秀的單元測試三支柱: **可讀的, 可維護的, 可靠的** - 很少有開發人員一開始進行單元測試時就撰寫出能夠信任的測試, 遵循原則並發揮想像力才能把事情做對. - 測試要獲得可靠信, 得保留好的測試且刪除或重構不好的測試. 這些過程是循環迭代的. - **測試要與被測試系統共同成長**
×
Sign in
Email
Password
Forgot password
or
By clicking below, you agree to our
terms of service
.
Sign in via Facebook
Sign in via Twitter
Sign in via GitHub
Sign in via Dropbox
Sign in with Wallet
Wallet (
)
Connect another wallet
New to HackMD?
Sign up