如何為 iOS 編寫自動化測試
已發表: 2022-03-11作為一名優秀的開發人員,您會盡最大努力測試您編寫的軟件中的所有功能以及所有可能的代碼路徑和結果。 但是,能夠手動測試用戶可能採取的每一個可能的結果和每一個可能的路徑是極其罕見和不尋常的。
隨著應用程序變得越來越大和越來越複雜,您通過手動測試錯過某些東西的可能性顯著增加。
UI 和後端服務 API 的自動化測試將使您更加確信一切都按預期工作,並在開發、重構、添加新功能或更改現有功能時減輕壓力。
通過自動化測試,您可以:
- 減少錯誤:沒有任何方法可以完全消除代碼中任何錯誤的可能性,但自動化測試可以大大減少錯誤的數量。
- 自信地進行更改:在添加新功能時避免錯誤,這意味著您可以快速輕鬆地進行更改。
- 記錄我們的代碼:在查看測試時,我們可以清楚地看到某些函數的預期、條件是什麼以及極端情況是什麼。
- 輕鬆重構:作為開發人員,您有時可能會害怕重構,尤其是當您需要重構大量代碼時。 單元測試是為了確保重構的代碼仍然按預期工作。
本文教你如何在 iOS 平台上構建和執行自動化測試。
單元測試與 UI 測試
區分單元測試和 UI 測試很重要。
單元測試在特定上下文下測試特定功能。 單元測試驗證代碼的測試部分(通常是單個函數)是否完成了它應該做的事情。 有很多關於單元測試的書籍和文章,所以我們不會在這篇文章中介紹。
UI 測試用於測試用戶界面。 例如,它可以讓您測試視圖是否按預期更新,或者在用戶與某個 UI 元素交互時觸發特定操作。
每個 UI 測試都測試特定用戶與應用程序 UI 的交互。 自動化測試可以而且應該在單元測試和 UI 測試級別上執行。
設置自動化測試
由於 XCode 支持開箱即用的單元和 UI 測試,因此可以輕鬆直接地將它們添加到您的項目中。 創建新項目時,只需選中“包含單元測試”和“包含 UI 測試”。
創建項目時,選中這兩個選項後,將向您的項目添加兩個新目標。 新的目標名稱在名稱末尾附加了“Tests”或“UITests”。
而已。 您已準備好為您的項目編寫自動化測試。
如果您已經有一個現有的項目並想要添加 UI 和單元測試支持,您將需要做更多的工作,但這也非常簡單明了。
轉到File → New → Target並選擇iOS Unit Testing Bundle進行單元測試或iOS UI Testing Bundle進行 UI 測試。
按下一步。
在目標選項屏幕中,您可以保留所有內容(如果您有多個目標並且只想測試特定目標,請在要測試的目標下拉列表中選擇目標)。
按完成。 對 UI 測試重複此步驟,您將準備好開始在現有項目中編寫自動化測試。
編寫單元測試
在我們開始編寫單元測試之前,我們必須了解它們的結構。 當您在項目中包含單元測試時,將創建一個示例測試類。 在我們的例子中,它看起來像這樣:
import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }
最重要的理解方法是setUp
和tearDown
。 setUp
方法在每個測試方法之前調用,而tearDown
方法在每個測試方法之後調用。 如果我們運行在這個示例測試類中定義的測試,這些方法將像這樣運行:
setUp → testExample → tearDown setUp → testPerformanceExample → tearDown
提示:通過按 cmd + U、選擇產品 → 測試或單擊並按住運行按鈕直到出現選項菜單,然後從菜單中選擇測試來運行測試。
如果您只想運行一種特定的測試方法,請按方法名稱左側的按鈕(如下圖所示)。
現在,當您準備好編寫測試的所有內容時,您可以添加一個示例類和一些方法進行測試。
添加一個負責用戶註冊的類。 用戶輸入電子郵件地址、密碼和密碼確認。 我們的示例類將驗證輸入,檢查電子郵件地址的可用性,並嘗試用戶註冊。
注意:此示例使用 MVVM(或 Model-View-ViewModel)架構模式。
使用 MVVM 是因為它使應用程序的架構更簡潔,更易於測試。
使用 MVVM,更容易將業務邏輯與表示邏輯分離,從而避免大量視圖控制器問題。
有關 MVVM 架構的詳細信息超出了本文的範圍,但您可以在本文中閱讀更多相關信息。
讓我們創建一個負責用戶註冊的視圖模型類。 .
class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }
首先,我們添加了一些屬性、動態屬性和一個 init 方法。
不要擔心Dynamic
類型。 它是 MVVM 架構的一部分。
當Dynamic<Bool>
值設置為 true 時,綁定(連接)到RegistrationViewModel
的視圖控制器將啟用註冊按鈕。 當loginSuccessful
設置為 true 時,連接的視圖將自行更新。
現在讓我們添加一些方法來檢查密碼和電子郵件格式的有效性。
func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }
每次用戶在電子郵件或密碼字段中輸入內容時, enableRegistrationAttempt
方法都會檢查電子郵件和密碼的格式是否正確,並通過registrationEnabled
動態屬性啟用或禁用註冊按鈕。
為了使示例簡單,添加兩種簡單的方法——一種檢查電子郵件可用性,另一種嘗試使用給定的用戶名和密碼進行註冊。
func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }
這兩種方法使用 NetworkService 來檢查電子郵件是否可用並嘗試註冊。
為了使這個示例簡單,NetworkService 實現不使用任何後端 API,而只是一個偽造結果的存根。 NetworkService 被實現為協議及其實現類。
typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }
NetworkService 是一個非常簡單的協議,僅包含兩種方法:註冊嘗試和電子郵件可用性檢查方法。 協議實現是 NetworkServiceImpl 類。
class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }
這兩種方法都只是等待一段時間(偽造網絡請求的時間延遲),然後調用適當的回調方法。
提示:使用協議(在其他編程語言中也稱為接口)是一種很好的做法。 如果您搜索“接口編程原理”,您可以閱讀更多相關信息。 您還將看到它在單元測試中的表現如何。
現在,當設置一個示例時,我們可以編寫單元測試來覆蓋這個類的方法。
為我們的視圖模型創建一個新的測試類。 右鍵單擊 Project Navigator 窗格中的
TestingIOSTests
文件夾,選擇 New File → Unit Test Case Class,並將其命名為RegistrationViewModelTests
。刪除
testExample
和testPerformanceExample
方法,因為我們要創建自己的測試方法。由於 Swift 使用模塊並且我們的測試與我們的應用程序代碼位於不同的模塊中,因此我們必須將應用程序的模塊導入為
@testable
。 在 import 語句和類定義下方,添加@testable import TestingIOS
(或您的應用程序的模塊名稱)。 沒有這個,我們將無法引用我們應用程序的任何類或方法。添加
registrationViewModel
變量。
這就是我們的空測試類現在的樣子:
import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }
讓我們嘗試為emailValid
方法編寫一個測試。 我們將創建一個名為testEmailValid
的新測試方法。 在名稱的開頭添加test
關鍵字很重要。 否則,該方法將不會被識別為測試方法。
我們的測試方法如下所示:
func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }
我們的測試方法使用斷言方法XCTAssert
,在我們的例子中它檢查條件是真還是假。
如果條件為假,assert 將失敗(與測試一起),我們的消息將被寫出。
您可以在測試中使用許多斷言方法。 描述和展示每個assert方法可以很容易地成為自己的文章,這裡不再贅述。
可用斷言方法的一些示例是: XCTAssertEqualObjects
、 XCTAssertGreaterThan
、 XCTAssertNil
、 XCTAssertTrue
或XCTAssertThrows
。
您可以在此處閱讀有關可用斷言方法的更多信息。
如果您現在運行測試,測試方法將通過。 你已經成功地創建了你的第一個測試方法,但它還沒有準備好迎接黃金時間。 這種測試方法仍然存在三個問題(一個大兩個小),如下所述。
問題 1:您正在使用 NetworkService 協議的真實實現
單元測試的核心原則之一是每個測試都應該獨立於任何外部因素或依賴項。 單元測試應該是原子的。
如果您正在測試一種方法,該方法有時會從服務器調用 API 方法,那麼您的測試依賴於您的網絡代碼和服務器的可用性。 如果測試時服務器不工作,你的測試就會失敗,從而錯誤地指責你的測試方法不工作。
在這種情況下,您正在測試RegistrationViewModel
的方法。
RegistrationViewModel
依賴於NetworkServiceImpl
類,即使您知道測試的方法emailValid
並不直接依賴於NetworkServiceImpl
。
編寫單元測試時,應刪除所有外部依賴項。 但是你應該如何在不改變RegistrationViewModel
類的實現的情況下移除 NetworkService 依賴呢?
這個問題有一個簡單的解決方案,它被稱為Object Mocking 。 如果您仔細查看RegistrationViewModel
,您會發現它實際上依賴於NetworkService
協議。
class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...
初始化RegistrationViewModel
時,將NetworkService
協議的實現提供(或註入)到RegistrationViewModel
對象。
這個原理被稱為通過構造函數的依賴注入(依賴注入的類型更多)。
網上有很多關於依賴注入的有趣文章,比如objc.io上的這篇文章。
這裡還有一篇簡短但有趣的文章,以簡單明了的方式解釋了依賴注入。
此外,Toptal 博客上還有一篇關於單一職責原則和 DI 的精彩文章。
當RegistrationViewModel
被實例化時,它會在其構造函數中註入一個 NetworkService 協議實現(因此是依賴注入原理的名稱):
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
由於我們的視圖模型類僅依賴於協議,因此沒有什麼能阻止我們創建自定義(或模擬) NetworkService
實現類並將模擬類註入到我們的視圖模型對像中。
讓我們創建我們的模擬NetworkService
協議實現。
右鍵單擊 Project Navigator 中的TestingIOSTests
文件夾,向我們的測試目標添加一個新的 Swift 文件,選擇“New File”,選擇“Swift file”,並將其命名為NetworkServiceMock
。
這就是我們模擬的類的外觀:
import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }
在這一點上,它與我們的實際實現( NetworkServiceImpl
)並沒有太大的不同,但在現實世界的情況下,實際的NetworkServiceImpl
將具有網絡代碼、響應處理和類似的功能。
我們的模擬類不做任何事情,這是模擬類的重點。 如果它不做任何事情,它就不會干擾我們的測試。
為了解決我們測試的第一個問題,讓我們通過替換來更新我們的測試方法:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
和:
let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())
問題 2:您在測試方法主體中實例化registrationVM
有setUp
和tearDown
方法是有原因的。
這些方法用於初始化或設置測試中所需的所有必需對象。 您應該使用這些方法通過在每個測試方法中編寫相同的 init 或 setup 方法來避免代碼重複。 不使用 setup 和 tearDown 方法並不總是一個大問題,特別是如果您對特定測試方法有非常特定的配置。
由於我們對RegistrationViewModel
類的初始化非常簡單,因此您將重構您的測試類以使用 setup 和 tearDown 方法。
RegistrationViewModelTests
應該如下所示:
class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }
問題 3:您在一種測試方法中有多個斷言
儘管這不是一個大問題,但有些人主張每個方法都有一個斷言。
該原理的主要原因是錯誤檢測。
如果一個測試方法有多個斷言並且第一個失敗,則整個測試方法將被標記為失敗。 其他斷言甚至不會被測試。
這樣,您一次只會發現一個錯誤。 您不會知道其他斷言是否會失敗或成功。
在一個方法中擁有多個斷言並不總是一件壞事,因為您一次只能修復一個錯誤,因此一次檢測一個錯誤可能不是什麼大問題。
在我們的例子中,測試了電子郵件格式的有效性。 由於這只是一個功能,因此將所有斷言組合在一種方法中可能更合乎邏輯,以使測試更易於閱讀和理解。
由於這個問題實際上並不是一個大問題,有些人甚至可能會爭辯說這根本不是問題,因此您將保持測試方法不變。
當您編寫自己的單元測試時,由您決定要為每種測試方法採用哪條路徑。 最有可能的是,您會發現在某些地方每個測試哲學的斷言是有意義的,而在其他地方則沒有。
使用異步調用的測試方法
無論應用程序多麼簡單,很有可能會有一個方法需要在另一個線程上異步執行,特別是因為您通常希望 UI 在自己的線程中執行。
單元測試和異步調用的主要問題是異步調用需要時間來完成,但單元測試不會等到它完成。 因為單元測試在異步塊內的任何代碼執行之前完成,所以我們的測試將始終以相同的結果結束(無論您在異步塊中寫什麼)。
為了演示這一點,讓我們為checkEmailAvailability
方法創建一個測試。
func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }
在這裡,您想測試在我們的方法告訴您電子郵件不可用(已被其他用戶接收)後,registrationEnabled 變量是否設置為 false。
如果您運行此測試,它將通過。 但只需再嘗試一件事。 將您的斷言更改為:
XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")
如果再次運行測試,它會再次通過。
這是因為我們的斷言甚至沒有被斷言。 單元測試在執行回調塊之前結束(請記住,在我們模擬的網絡服務實現中,它被設置為等待一秒鐘然後返回)。
幸運的是,在 Xcode 6 中,Apple 已將測試預期作為XCTestExpectation
類添加到 XCTest 框架中。 XCTestExpectation
類的工作方式如下:

- 在測試開始時,您設置測試期望- 使用簡單的文本描述您對測試的期望。
- 在執行測試代碼後的異步塊中,您就可以滿足期望。
- 在測試結束時,您需要設置
waitForExpectationWithTimer
塊。 它將在滿足期望或計時器用完時執行 - 以先發生者為準。 - 現在,在滿足期望或期望計時器用完之前,單元測試不會完成。
讓我們重寫我們的測試以使用XCTestExpectation
類。
func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }
如果您現在運行測試,它將失敗 - 應該如此。 讓我們修復測試以使其通過。 將斷言更改為:
XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")
再次運行測試以查看它是否通過。 您可以嘗試更改網絡服務模擬實現中的延遲時間,以查看如果預期計時器用完會發生什麼。
使用沒有回調的異步調用測試方法
我們的示例項目方法attemptUserRegistration
使用NetworkService.attemptRegistration
方法,其中包括異步執行的代碼。 該方法嘗試向後端服務註冊用戶。
在我們的演示應用程序中,該方法將只等待一秒鐘來模擬網絡調用,並假裝成功註冊。 如果註冊成功,則loginSuccessful
值將設置為 true。 讓我們做一個單元測試來驗證這個行為。
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }
如果運行,此測試將失敗,因為在異步networkService.attemptRegistration
方法完成之前, loginSuccessful
值不會設置為 true。
由於您創建了一個模擬的NetworkServiceImpl
,其中attemptRegistration
方法將在返回成功註冊之前等待一秒鐘,您可以只使用 Grand Central Dispatch (GCD),並在一秒鐘後使用asyncAfter
方法檢查您的斷言。 添加 GCD 的asyncAfter
之後,我們的測試代碼將如下所示:
func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }
如果你注意了,你就會知道這仍然行不通,因為測試方法將在asyncAfter
塊執行之前執行,結果方法總是會成功通過。 幸運的是,有XCTestException
類。
讓我們重寫我們的方法以使用XCTestException
類:
func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }
通過覆蓋我們的RegistrationViewModel
的單元測試,您現在可以更加確信添加新功能或更新現有功能不會破壞任何東西。
重要提示:如果單元測試在其涵蓋的方法的功能發生變化時沒有更新,它們將失去其價值。 編寫單元測試是一個必須跟上應用程序其餘部分的過程。
提示:不要將編寫測試推遲到最後。 在開發時編寫測試。 這樣,您將更好地了解需要測試的內容以及邊界情況。
編寫 UI 測試
在所有單元測試都完全開發並成功執行之後,您可以非常確信每個代碼單元都正常工作,但這是否意味著您的應用程序作為一個整體按預期工作?
這就是集成測試的用武之地,其中 UI 測試是必不可少的組件。
在開始進行 UI 測試之前,需要對一些 UI 元素和交互(或用戶故事)進行測試。 讓我們創建一個簡單的視圖及其視圖控制器。
- 打開
Main.storyboard
並創建一個簡單的視圖控制器,如下圖所示。
將電子郵件文本字段標籤設置為 100,密碼文本字段標籤設置為 101,密碼確認標籤設置為 102。
- 添加一個新的視圖控制器文件
RegistrationViewController.swift
並將所有出口與故事板連接。
import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }
在這裡,您將IBOutlets
和TextFieldTags
結構添加到類中。
這將使您能夠識別正在編輯的文本字段。 要使用視圖模型中的動態屬性,您必須在視圖控制器中“綁定”動態屬性。 您可以在bindViewModel
方法中執行此操作:
fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }
現在讓我們添加一個文本字段委託方法來跟踪任何文本字段的更新時間:
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
- 更新
AppDelegate
以將視圖控制器綁定到適當的視圖模型(請注意,此步驟是 MVVM 架構的要求)。 更新後的AppDelegate
代碼應如下所示:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }
故事板文件和RegistrationViewController
非常簡單,但它們足以演示自動化 UI 測試的工作原理。
如果一切設置正確,則應在應用程序啟動時禁用註冊按鈕。 當且僅當所有字段均已填寫且有效時,才應啟用註冊按鈕。
設置完成後,您可以創建您的第一個 UI 測試。
當且僅當有效的電子郵件地址、有效的密碼和有效的密碼確認都已輸入時,我們的 UI 測試應檢查註冊按鈕是否啟用。 設置方法如下:
- 打開
TestingIOSUITests.swift
文件。 - 刪除
testExample()
方法並添加testRegistrationButtonEnabled()
方法。 - 將光標放在
testRegistrationButtonEnabled
方法中,就像您要在那裡寫東西一樣。 - 按下 Record UI 測試按鈕(屏幕底部的紅色圓圈)。
- 按下錄製按鈕時,將啟動應用程序
- 應用程序啟動後,點擊電子郵件文本字段並輸入“[email protected]”。 您會注意到代碼自動出現在測試方法主體中。
您可以使用此功能記錄所有 UI 指令,但您可能會發現手動編寫簡單指令會快得多。
這是一個記錄器指令示例,用於點擊密碼文本字段並輸入電子郵件地址“[email protected]”
let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
- 錄製完要測試的 UI 交互後,再次按下停止按鈕(開始錄製時錄製按鈕標籤更改為停止)以停止錄製。
- 擁有 UI 交互記錄器後,您現在可以添加各種
XCTAsserts
來測試應用程序或 UI 元素的各種狀態。
記錄的說明並不總是一目了然,甚至可能使整個測試方法有點難以閱讀和理解。 幸運的是,您可以手動輸入 UI 說明。
讓我們手動創建以下 UI 指令:
- 用戶點擊密碼文本字段。
- 用戶輸入“密碼”。
To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.
The password UI interaction can now be written as:
let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")
There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:
let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")
Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert
(the same as you did in unit testing) to verify if the Register button's isEnabled
state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled
property looks like this:
let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")
The whole UI test should now look like this:
func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }
If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.
To improve the test, let's also test that the isEnabled
property of the register button is false whenever any of the required fields have not been not entered correctly.
The complete test method should now look like this:
func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }
Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.
The example UI test is very simple, but it demonstrates the power of automated UI testing.
The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.
Become a Better Developer by Writing Good Tests
From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.
To write good tests, you will have to learn how to better organize your code.
Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.
In some cases, it is even impossible to write tests when code is not organized well.
When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).
When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.
Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.
As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).
Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.