notion 貼過來圖壞光光...請移駕到我 notion: https://www.notion.so/Ch3-c7208d83c72c41ae8307dccec5a27da0 --- ###### tags: `Allen` # Section II: Beginning TDD - 這個 section 教你基本的 TDD 再 app 專案上,包含 setup, test expressions(表達), dependency injection, mocks, test expectations - 我們會建立一個 firness app - **Ch3: TDD app setup**: 給你一個 Xcode 的測試方式, 可以學到一些 TDD 的重要 concepts - **Ch4: Test Expression**: `XCTAssert` 是測試主要的判斷方法,還有學習操作 app 去驅動 view controller unit testing, 以及取得 code coverage, 最後,使用 test debugger 去找到 test errors - **Ch5: Test Expectations**: `XCTestExpectation` - **Ch6: Dependency Ingection & Mocks**: use mocks to 測試 code 依賴系統或已存在的 services 並且不需要真的去 call services(他們也許是還不是 available, usabel, reliable), 可以測試錯誤條件並將邏輯與 sdk 隔離 ## Chapter 3: TDD App Setup - TDD 可以幫助寫出 clean, 簡潔, 正確的 code - 這章節主要再告訴你 Xcode 的測試方式 - 這章節數可以學會建立 test target and run unit test - 撰寫 unit test ### About the FiNess app - 這個 section 我們會寫一個 app: FitNess ### Yout first test - 必須要有 `test target` 才可以跑測試 - test code 不用部署到 production app 中 - TDD 的精神是測試是 first-class code 意思是他應該跟你的 production code 一樣標準,不論是可讀性、命名、錯誤處理或 coding conventions ### Adding a test target - 建立一個 test target, 按下 TARGET 下的 +: ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/61e7b404-0560-432d-9b2c-26ce8fb4a705/_2021-05-24_00.29.47.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/61e7b404-0560-432d-9b2c-26ce8fb4a705/_2021-05-24_00.29.47.png) - 選擇 iOS Unit Testing Bundle ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/52c569c0-79d2-4d62-97c5-fef145461dba/_2021-05-24_00.30.38.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/52c569c0-79d2-4d62-97c5-fef145461dba/_2021-05-24_00.30.38.png) - 確保選擇的 Target to be Tested: 是 FitNess, Peoduction Name 是 `{Project_Name}Tests` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2b7b5f25-82be-45a8-9a75-f158396ba2ec/_2021-05-24_00.33.01.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2b7b5f25-82be-45a8-9a75-f158396ba2ec/_2021-05-24_00.33.01.png) - Xcode 會幫我們自動建立 FitNessTests test target and FitNessTests goroup, 裡面有 `FitNessTests.swift`, `Info.plist` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/79867228-fd1f-4b6a-9370-ca37f0b2519a/_2021-05-24_00.42.10.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/79867228-fd1f-4b6a-9370-ca37f0b2519a/_2021-05-24_00.42.10.png) ### Figuring out what to test - 書中叫我們刪掉 `FitNessTests.swift` - 目前 app 沒有任何商業邏輯,只有一個 start 按鈕,我們從 start 開始 - TDD 需要先寫測試,必須是最小的單元功能(做某件事中的最小的事( the samllest thing that does something)) - `App Model` 知道現在這個 app 處於什麼狀態 ```swift public enum AppState { case notStarted, inProgress, paused, completed, caught } ``` - start button 的最小單元功能就是按下這個按鈕讓 app 進入各種狀態 - 有兩種情境: 1. app 應該從 `.notStarted` 開始,這時候 UI 應該有 welcome messaging 2. 當 user tap Start button, app 應該變為 `.inProgress` 然後 app 開始運作 - 這些情境感覺像是 use case, 用來定義測試項目 ### Adding a test class - command + N 新增 file, 選擇 `Unit Test Case Class` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/02db5caa-062a-46e1-96f1-1c336ea25725/_2021-05-24_01.00.49.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/02db5caa-062a-46e1-96f1-1c336ea25725/_2021-05-24_01.00.49.png) - Class name: `{TestClass}Tests` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e3ccae28-0795-42e3-8d49-86e05afa0b7b/_2021-05-24_01.03.29.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e3ccae28-0795-42e3-8d49-86e05afa0b7b/_2021-05-24_01.03.29.png) - 確定只選擇同名的 target, 如果 Xcode 問要不要建立 Objective-C bridging header, 選擇 Don't Create(如果是 pure swift project) ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d3b68a1f-ccb1-4fd1-a7d7-34857533617e/_2021-05-24_01.01.35.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d3b68a1f-ccb1-4fd1-a7d7-34857533617e/_2021-05-24_01.01.35.png) - 現在有一個 test template, 刪掉 `testExample()` & `testPerformanceExample()` and 先忽略 `setUp()` and `tearDown()` ### Red-Green-Refactor - Write a test that fails (red) - Write the minimum amout of code so the test passes (green) - Clean up test(s) and code as needed (refactor) - Repeat the process until all the logic cases are covered ### Writing a red test - 新增第一個 failing-to-compile test ```swift func testAppModel_whenInitialized_isInNotStartedState() { let sut = AppModel() let initialState = sut.appState XCTAssert(initialState, AppState.notStarted) } ``` - 有幾種方式可以跑 test - 按下 test method 前的菱形跑單獨 method 的測試 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f45bd182-6c75-4acb-bb8e-ac40140f14c7/_2021-05-24_21.03.59.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f45bd182-6c75-4acb-bb8e-ac40140f14c7/_2021-05-24_21.03.59.png) - 可以按 `Test navigator` 的 play button ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/7229d182-1e12-489e-93a7-2d8fef58432c/_2021-05-24_20.37.46.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/7229d182-1e12-489e-93a7-2d8fef58432c/_2021-05-24_20.37.46.png) - Product → Test (Command + U) 跑所有的測試 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/ee657c7b-8702-435d-9a7e-f178fa9ac29b/_2021-05-24_20.44.28.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/ee657c7b-8702-435d-9a7e-f178fa9ac29b/_2021-05-24_20.44.28.png) - Control + Option + Command + U, 游標在哪裡就跑哪個 test, 如果在 test method 外面就跑整個 file 的測試(not working) - 補充:Control + Option + Command + G → 再跑一次最後測試(ref: [https://stackoverflow.com/questions/28057643/is-there-a-keyboard-shortcut-in-xcode-6-to-run-a-single-current-test-function-un](https://stackoverflow.com/questions/28057643/is-there-a-keyboard-shortcut-in-xcode-6-to-run-a-single-current-test-function-un)) - 現在執行應該會看到兩條錯誤,failing test, 在這個階段是必須的 ### Making the test green - 上面測試不過第一個問題是 test 不知道 `AppModel`, 必須加上 `import FitNess` - In Xcode, 雖然 app target 不是 framework 是 module, 但 test target 可以 import app target - Product → Build For → Testing (Shift + Command + U): re-build app for testing - 這時候還是會有 error, `Value of type 'AppModel' has no member 'appState'` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/5c1de37a-09dd-4de6-9c05-e15db7f4c0ae/_2021-05-24_21.04.52.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/5c1de37a-09dd-4de6-9c05-e15db7f4c0ae/_2021-05-24_21.04.52.png) - 宣告 appState ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/065e9489-8516-432e-86fd-1292ff2558b8/_2021-05-24_21.00.55.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/065e9489-8516-432e-86fd-1292ff2558b8/_2021-05-24_21.00.55.png) - Green ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/432b8627-ae8d-4e15-abc3-25a151888e45/_2021-05-24_21.09.31.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/432b8627-ae8d-4e15-abc3-25a151888e45/_2021-05-24_21.09.31.png) - 寫了一個簡單的 test, 只是測試 default value, 也沒有可以 refactor 的地方,所以不用 refactor ### Writing a more interesting test - 接下來要測試 app 從 not started → in-progress ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/299ed970-2f1f-4ede-a276-cf02f88c6ec2/_2021-05-24_21.16.09.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/299ed970-2f1f-4ede-a276-cf02f88c6ec2/_2021-05-24_21.16.09.png) - 三個階段:given, when, then: 1. given: 建立 AppModel, 我們前面已經確保 AppModel init status is .notStarted 2. when: 一個我們還沒建立的 start 行為 3. then: 驗證 state 是否從 .notStarted → .inProgress - 一樣會先從 Red 開始,為了解決 red, 我們在 `AppModel` 新增一個 `public func start()` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f073d6a1-1fa2-4758-9e94-e78889ea4dae/_2021-05-24_21.22.10.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f073d6a1-1fa2-4758-9e94-e78889ea4dae/_2021-05-24_21.22.10.png) - 為了解決這個錯誤,我們寫下最低限度的 code 讓 test passes: ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/99809052-b9cb-488b-b312-f5f67ff0053a/_2021-05-24_21.23.46.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/99809052-b9cb-488b-b312-f5f67ff0053a/_2021-05-24_21.23.46.png) ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/02b4ee50-096b-4eb4-8ac8-f5534a72ef82/_2021-05-24_21.23.36.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/02b4ee50-096b-4eb4-8ac8-f5534a72ef82/_2021-05-24_21.23.36.png) - 上面那邊空的 `start()` 這個步驟可以跳過,不過每次只撰寫最小限度的 code 這個原則還是在,這是 tdd cycle 的核心,且可以確保足夠的測試覆蓋 ### Test nomenclature - 這邊寫下一些測試的 naming convention - test function 應該明確描述測試內容,避免建立 test1, test2 這種 - 命名方法建議(以上面的 `func testAppModel_whenStarted_isInInProgressState(` 為例): - 開頭一定是 `test` - AppModel 表示 system under test(sut) is AppModel(given) - `whenStarted` 表示測試的條件或狀態變化(when) - `isInInProgressState` 表示sut 在 when 以後的狀態變化應該為何(then) - `test{given}_{when}_{then}` - 有助於確定這些測試是在測試哪些東西哪些條件,如果測試名稱跟其他測項名稱方向不同的很可能就是屬於其他的測試 --- - `let sut = AppModel()` - 透過命名 sut 讓待測物件明確 - `sut.start()` - 測試物件的行為 start - `let observedState = sut.appState` - 定義一個屬性用在後面預期的狀態 - `XCTAssertEqual(observedSate, AppState.inProgress)` - 確定我們的 when 做的事情跟預期結果符合 - 再次強調 given / when / then - given 就是給定初始內容 - when 就是行為 - then 就是在 when 後比較預期結果 - 重申上面的 naimg convention 不一定要照做,重要的事 red → green → refactor → repeat ### Structure of XCTestCase subclass - XCTest 從 XUnit 衍生的測試框架 - test 開頭的規範就是 XUnit 來的 - 每個 test case class 都有 `setUp()` and `tearDonw()` - `XCTestCase` subclass 的 lifecycle 是在執行測試外管理,任何 class-level state 會保存在各個 test methods - test classes / methods 沒有定義執行順序,所以彼此無法相依 - 所以 `setUp()` init 狀態 & `tearDown()` release 很重要 ### Setting up a test - 如果測試項目中需要測試同一個物件,我們可以宣告在外面: - `var sut: AppModel!` - 我們 force-unwrapped, 因為我們會在 setUp 初始化它 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/06a395ff-a4db-4f8d-ba80-ee25c1e8814a/_2021-05-24_22.10.07.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/06a395ff-a4db-4f8d-ba80-ee25c1e8814a/_2021-05-24_22.10.07.png) - 這樣我們就可以把剛剛了個測試的 given 刪掉了 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/518fb582-082e-49ba-bcf4-414ba6823402/_2021-05-24_22.10.39.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/518fb582-082e-49ba-bcf4-414ba6823402/_2021-05-24_22.10.39.png) - 如果我們沒有 `setUp()` , 那測試的順序就很重要,因為 `testAppModel_whenInitialized_isInNotStartedState` 要 appState == .notStarted, 但因為我們有 setUp & tearDown, 所以每次都會重新 init ### Teearing down a test - 要整個 tests 完成後才會 deinitialized, 所以清理資料,或復原初始狀態很重要 ```swift override func tearDownWithError() throws { sut = nil super.tearDown() } ``` ### Your next set of tests - 接下來要加增加一些 user-visible functionality, 當然也是先寫測試(TDD) - 接下來新增一個 `StepCountControllerTests` for `StepCountController` ### Test target organization - 書中會利用下列結構組織測試程式碼: - Test Target - Cases - Group 1 - Tests 1 - Tests 2 - Group 2 - Tests - Mocks - Helper Classes - Helper Extensions - Clases: 讓測試的組織階層跟 production code 的組織階層相同,我們可以更容易找到我們的測試,如下: ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/89219071-de52-4703-b724-64409802f650/_2021-05-24_22.33.22.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/89219071-de52-4703-b724-64409802f650/_2021-05-24_22.33.22.png) - Mocks: 代表一些功能的 code, 例如我們常會 mock networking - Helper classes and extensions: 讓撰寫測試 code 更容易,但不要在這邊寫測試 or mocks ### Using @testable import - 加入 `var sut: StepCountController!` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/4d4bb2ff-da1c-4ccb-837d-124076bbe32a/_2021-05-24_22.38.48.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/4d4bb2ff-da1c-4ccb-837d-124076bbe32a/_2021-05-24_22.38.48.png) - 會有 error, 因為 StepCountController 是 internal 的,雖然可以宣告為 `public` , 但這樣違反了 SOLID principles - Xcode 提供一個可以揭露 data type 但不影響一般使用的方法,`@testable` - 現在我們可以存取 open, public, internal 物件了,但這個只在 test target 有效,現在可以 build 了 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9c373982-8e62-47b9-9366-4f3d7d24100f/_2021-05-24_22.47.05.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9c373982-8e62-47b9-9366-4f3d7d24100f/_2021-05-24_22.47.05.png) ### Testing a state change - 接下來撰寫測試 uset 按下 Start button, 有兩件事會發生 - app state update → testController_whenStartTapped_appIsInProgress() - UI update → testController_whenStartTapped_buttonLabelIsPause() - 先 app state update ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/3ecd5b7d-630b-47b2-a1e6-925affcddeb8/_2021-05-24_22.52.10.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/3ecd5b7d-630b-47b2-a1e6-925affcddeb8/_2021-05-24_22.52.10.png) - 因為按下 start (startStopPause) 沒有寫 function code (Red) - 接下來我們補上 startStopPause code 讓 test green ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/cb5a022a-2f6b-458e-abf6-548ee6b27fd2/_2021-05-24_22.54.15.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/cb5a022a-2f6b-458e-abf6-548ee6b27fd2/_2021-05-24_22.54.15.png) ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/18dce439-1d97-43a8-b1b2-958047625b56/_2021-05-24_22.54.39.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/18dce439-1d97-43a8-b1b2-958047625b56/_2021-05-24_22.54.39.png) ### Testing UI updates - UI 有 UI Automation(本書沒有), 不過 UI 也有很多可以做 unit test 的地方 - 再來寫 UI update test ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/901b4656-150a-4567-827d-2662f060e323/_2021-05-24_22.59.51.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/901b4656-150a-4567-827d-2662f060e323/_2021-05-24_22.59.51.png) - 測試 start button 按下是否更新 ui - 可以注意到這兩個 test 有相同的 init state & "when" 行為 - 只有差在做同一個動作,測試不同的狀態 update - TDD 建議一個 test 只有一個 assert, 對應一個 well-named test methods, 這樣出問題可以很容易發現問題在哪 - 上面測試另一個值得注意的是使用 `AppState.inProgress.nextStateButtonLabel` 而不是 hard-code string, assert 測試行為而不是固定的值,這樣如果 string 要更改或 localization, 我們不會因此錯誤 - 上面的測試會錯誤,因為我們還沒修改 ui update(red) - 一樣的,撰寫最小可行的 code ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2d89a0b2-dd5a-4f64-8345-2f63220e874f/_2021-05-24_23.07.42.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2d89a0b2-dd5a-4f64-8345-2f63220e874f/_2021-05-24_23.07.42.png) - 過溜 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/71f6b9e2-a0dc-4ed1-a3a8-9aa50b26e907/_2021-05-24_23.08.35.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/71f6b9e2-a0dc-4ed1-a3a8-9aa50b26e907/_2021-05-24_23.08.35.png) ### Testing initial conditions - 剛剛那兩個測試項目只能測出指定的初始條件,如果 vc 已經是 .inProgress 那測試也會通過 - 要撰寫完整的 unit test 有一部分是要將假設變成明確的條件 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/11a545cd-16c9-489d-b8fb-654e81abee51/_2021-05-24_23.14.29.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/11a545cd-16c9-489d-b8fb-654e81abee51/_2021-05-24_23.14.29.png) - 這個 test case 確保 start button 在 init 後一定要是 .notStarted 狀態 - 使用 `MARK: - ...` 去分類測試,就跟我們寫 production code 一樣 - 測試失敗,我們在 `viewDidLoad` 補上 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/29245b66-6420-4f61-9122-a3c5385996b3/_2021-05-24_23.18.54.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/29245b66-6420-4f61-9122-a3c5385996b3/_2021-05-24_23.18.54.png) - 還要補上 `sut.viewDidLoad()` ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/aa422fa4-f919-48e4-b01d-5de4c8ffd3e9/_2021-05-24_23.20.36.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/aa422fa4-f919-48e4-b01d-5de4c8ffd3e9/_2021-05-24_23.20.36.png) - 為什麼要 call `viewDidLoad()` 是因為 sut 在前面 setUp 並沒有真的 load xib/storyboard 進 view hierarchy, 所以 view lifecycle(viewDidLoad, etc.) 並沒有真的 call ### Refactoring - 現在 code 裡面有很多重複的、多餘的 code, 當我們所有的 test case 都 pass, 我們可以 refactor code 使其更高效、更易讀、更可維護,可以任意更改 production or test code, 因為我們已經有 test case 保護 ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d7d919ca-5ae6-4cc9-9cd8-03f63f0a07f5/_2021-05-24_23.29.18.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d7d919ca-5ae6-4cc9-9cd8-03f63f0a07f5/_2021-05-24_23.29.18.png) - 我們改變了 code, 測試任然通過,表示行為不變 - 上面的行為可能稱為 Extract Method(Editor → Refactor) ### Challenge - AppModel from public → internal ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/43421949-024c-4361-9146-e690817ddd6b/_2021-05-24_23.32.41.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/43421949-024c-4361-9146-e690817ddd6b/_2021-05-24_23.32.41.png) ![https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2121d67d-315a-435e-99e6-9ad7491e112c/_2021-05-24_23.33.01.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2121d67d-315a-435e-99e6-9ad7491e112c/_2021-05-24_23.33.01.png) ### Key points - TDD 就是一個在寫 app logic 前寫測試的方法論 - 使用 given / when / then 來定義我們要測試什麼 - 每個測試在第一次執行都應該要失敗 - 利用測試來重構,讓代碼更具有可讀性及更好的效能 - 好的 naming conventions 讓我們更容易找到問題(test{Given}_{When}_{Then}) ### 心得 - Red → Green → Refactor → Repeat - Given → When → Then - test method naming convention: `test{given}_{when}_{then}` - 讓 test case 結構跟 production code 一致