如何为 iOS 编写自动化测试

已发表: 2022-03-11

作为一名优秀的开发人员,您会尽最大努力测试您编写的软件中的所有功能以及所有可能的代码路径和结果。 但是,能够手动测试用户可能采取的每一个可能的结果和每一个可能的路径是极其罕见和不寻常的。

随着应用程序变得越来越大和越来越复杂,您通过手动测试错过某些东西的可能性显着增加。

UI 和后端服务 API 的自动化测试将使您更加确信一切都按预期工作,并在开发、重构、添加新功能或更改现有功能时减轻压力。

通过自动化测试,您可以:

  • 减少错误:没有任何方法可以完全消除代码中任何错误的可能性,但自动化测试可以大大减少错误的数量。
  • 自信地进行更改:在添加新功能时避免错误,这意味着您可以快速轻松地进行更改。
  • 记录我们的代码:在查看测试时,我们可以清楚地看到某些函数的预期、条件是什么以及极端情况是什么。
  • 轻松重构:作为开发人员,您有时可能会害怕重构,尤其是当您需要重构大量代码时。 单元测试是为了确保重构的代码仍然按预期工作。

本文教你如何在 iOS 平台上构建和执行自动化测试。

单元测试与 UI 测试

区分单元测试和 UI 测试很重要。

单元测试特定上下文下测试特定功能。 单元测试验证代码的测试部分(通常是单个函数)是否完成了它应该做的事情。 有很多关于单元测试的书籍和文章,所以我们不会在这篇文章中介绍。

UI 测试用于测试用户界面。 例如,它可以让您测试视图是否按预期更新,或者在用户与某个 UI 元素交互时触发特定操作。

每个 UI 测试都测试特定用户与应用程序 UI 的交互。 自动化测试可以而且应该在单元测试和 UI 测试级别上执行。

设置自动化测试

由于 XCode 支持开箱即用的单元和 UI 测试,因此可以轻松直接地将它们添加到您的项目中。 创建新项目时,只需选中“包含单元测试”和“包含 UI 测试”。

创建项目时,选中这两个选项后,将向您的项目添加两个新目标。 新的目标名称在名称末尾附加了“Tests”或“UITests”。

而已。 您已准备好为您的项目编写自动化测试。

图片:在 XCode 中设置自动化测试。

如果您已经有一个现有的项目并想要添加 UI 和单元测试支持,您将需要做更多的工作,但这也非常简单明了。

转到File → New → Target并选择iOS Unit Testing Bundle进行单元测试或iOS UI Testing Bundle进行 UI 测试。

图片:选择 iOS 单元测试包。

下一步

在目标选项屏幕中,您可以保留所有内容(如果您有多个目标并且只想测试特定目标,请在要测试的目标下拉列表中选择目标)。

完成。 对 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. } } }

最重要的理解方法是setUptearDownsetUp方法在每个测试方法之前调用,而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) }) } }

这两种方法都只是等待一段时间(伪造网络请求的时间延迟),然后调用适当的回调方法。

提示:使用协议(在其他编程语言中也称为接口)是一种很好的做法。 如果您搜索“接口编程原理”,您可以阅读更多相关信息。 您还将看到它在单元测试中的表现如何。

现在,当设置一个示例时,我们可以编写单元测试来覆盖这个类的方法。

  1. 为我们的视图模型创建一个新的测试类。 右键单击 Project Navigator 窗格中的TestingIOSTests文件夹,选择 New File → Unit Test Case Class,并将其命名为RegistrationViewModelTests

  2. 删除testExampletestPerformanceExample方法,因为我们要创建自己的测试方法。

  3. 由于 Swift 使用模块并且我们的测试与我们的应用程序代码位于不同的模块中,因此我们必须将应用程序的模块导入为@testable 。 在 import 语句和类定义下方,添加@testable import TestingIOS (或您的应用程序的模块名称)。 没有这个,我们将无法引用我们应用程序的任何类或方法。

  4. 添加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方法可以很容易地成为自己的文章,这里不再赘述。

可用断言方法的一些示例是: XCTAssertEqualObjectsXCTAssertGreaterThanXCTAssertNilXCTAssertTrueXCTAssertThrows

您可以在此处阅读有关可用断言方法的更多信息。

如果您现在运行测试,测试方法将通过。 你已经成功地创建了你的第一个测试方法,但它还没有准备好迎接黄金时间。 这种测试方法仍然存在三个问题(一个大两个小),如下所述。

问题 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

setUptearDown方法是有原因的。

这些方法用于初始化或设置测试中所需的所有必需对象。 您应该使用这些方法通过在每个测试方法中编写相同的 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类的工作方式如下:

  1. 在测试开始时,您设置测试期望- 使用简单的文本描述您对测试的期望。
  2. 在执行测试代码后的异步块中,您就可以满足期望。
  3. 在测试结束时,您需要设置waitForExpectationWithTimer块。 它将在满足期望或计时器用完时执行 - 以先发生者为准。
  4. 现在,在满足期望或期望计时器用完之前,单元测试不会完成。

让我们重写我们的测试以使用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 元素和交互(或用户故事)进行测试。 让我们创建一个简单的视图及其视图控制器。

  1. 打开Main.storyboard并创建一个简单的视图控制器,如下图所示。

图片:创建一个简单的视图及其视图控制器。

将电子邮件文本字段标签设置为 100,密码文本字段标签设置为 101,密码确认标签设置为 102。

  1. 添加一个新的视图控制器文件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() } }

在这里,您将IBOutletsTextFieldTags结构添加到类中。

这将使您能够识别正在编辑的文本字段。 要使用视图模型中的动态属性,您必须在视图控制器中“绑定”动态属性。 您可以在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 }
  1. 更新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 测试应检查注册按钮是否启用。 设置方法如下:

  1. 打开TestingIOSUITests.swift文件。
  2. 删除testExample()方法并添加testRegistrationButtonEnabled()方法。
  3. 将光标放在testRegistrationButtonEnabled方法中,就像您要在那里写东西一样。
  4. 按下 Record UI 测试按钮(屏幕底部的红色圆圈)。

图片:显示录制 UI 测试按钮的屏幕截图。

  1. 按下录制按钮时,将启动应用程序
  2. 应用程序启动后,点击电子邮件文本字段并输入“[email protected]”。 您会注意到代码自动出现在测试方法主体中。

您可以使用此功能记录所有 UI 指令,但您可能会发现手动编写简单指令会快得多。

这是一个记录器指令示例,用于点击密码文本字段并输入电子邮件地址“[email protected]

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. 录制完要测试的 UI 交互后,再次按下停止按钮(开始录制时录制按钮标签更改为停止)以停止录制。
  2. 拥有 UI 交互记录器后,您现在可以添加各种XCTAsserts来测试应用程序或 UI 元素的各种状态。

图片:动画显示了点击密码字段的记录器指令。

记录的说明并不总是一目了然,甚至可能使整个测试方法有点难以阅读和理解。 幸运的是,您可以手动输入 UI 说明。

让我们手动创建以下 UI 指令:

  1. User taps on the password text field.
  2. User enters a 'password'.

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.