本文概述
作为一名优秀的开发人员, 你将尽力在编写的软件中测试所有功能以及所有可能的代码路径和结果。但是, 能够手动测试用户可能采取的每种可能结果和每种可能途径的情况极为罕见, 而且非常不寻常。
随着应用程序变得越来越大和越来越复杂, 你通过手动测试错过某些东西的可能性将大大增加。
UI和后端服务API的自动化测试将使你更有信心, 一切都会按预期进行, 并在开发, 重构, 添加新功能或更改现有功能时减轻压力。
借助自动化测试, 你可以:
- 减少错误:没有方法可以完全消除代码中任何错误的可能性, 但是自动测试可以大大减少错误的数量。
- 放心地进行更改:添加新功能时避免错误, 这意味着你可以快速而轻松地进行更改。
- 记录我们的代码:查看测试时, 我们可以清楚地看到某些功能的期望值, 条件和极端情况。
- 毫不费力地进行重构:作为开发人员, 你有时可能会担心重构, 尤其是在需要重构大量代码的情况下。此处进行单元测试以确保重构的代码仍能按预期工作。
本文教你如何在iOS平台上构建和执行自动化测试。
单元测试与UI测试
区分单元测试和UI测试非常重要。
单元测试在特定上下文中测试特定功能。单元测试可以验证代码中经过测试的部分(通常是单个函数)是否可以实现预期的功能。关于单元测试的书籍和文章很多, 因此在本文中我们将不作介绍。
UI测试用于测试用户界面。例如, 它使你可以测试视图是否按预期进行了更新, 或者触发了特定操作, 即当用户与某个UI元素进行交互时应该触发该操作。
每个用户界面测试都会测试特定用户与应用程序用户界面的交互。可以并且应该在单元测试和UI测试级别上执行自动测试。
设置自动化测试
由于XCode支持开箱即用的单元和UI测试, 因此将它们添加到你的项目中既简单又直接。创建新项目时, 只需选中”包括单元测试”和”包括UI测试”。
创建项目后, 选中这两个选项后, 将向你的项目添加两个新目标。新的目标名称在名称末尾附加了”测试”或” UITests”。
而已。你已准备好为项目编写自动化测试。
如果你已经有一个现有项目, 并且想要添加UI和单元测试支持, 则你将需要做更多的工作, 但这也非常简单明了。
转到文件→新建→目标, 然后选择用于单元测试的iOS单元测试包或用于UI测试的iOS 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方法。
不用担心动态类型。它是MVVM体系结构的一部分。
当Dynamic <Bool>值设置为true时, 绑定(连接)到RegistrationViewModel的视图控制器将启用注册按钮。当loginSuccessful设置为true时, 连接的视图将自动更新。
现在, 我们添加一些方法来检查密码和电子邮件格式的有效性。
func enableRegistrationAttempt() {
registrationEnabled.value = emailValid() && passwordValid()
}
func emailValid() -> Bool {
let emailRegEx = "[A-Z0-9a-z._%+-][email protected][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)
})
}
}
这两种方法都只是等待一段时间(假装网络请求的时间延迟), 然后调用适当的回调方法。
提示:最好使用协议(在其他编程语言中也称为接口)。如果你搜索”编程到接口原理”, 则可以阅读更多内容。你还将看到它在单元测试中的表现。
现在, 在设置示例后, 我们可以编写单元测试以涵盖此类的方法。
-
为我们的视图模型创建一个新的测试类。右键单击”项目导航器”窗格中的TestingIOSTests文件夹, 选择”新建文件”→”单元测试用例类”, 并将其命名为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的新测试方法。在名称的开头添加测试关键字很重要。否则, 该方法将不会被视为测试方法。
我们的测试方法如下:
func testEmailValid() {
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
registrationVM.emailAddress = "email.test.com"
XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct")
registrationVM.emailAddress = "[email protected]"
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的方法。
即使你知道你测试的方法emailValid并不直接依赖于NetworkServiceImpl, RegistrationViewModel仍依赖于NetworkServiceImpl类。
在编写单元测试时, 应删除所有外部依赖项。但是, 如何在不更改RegistrationViewModel类的实现的情况下删除NetworkService依赖关系?
有一个简单的解决方案, 称为对象模拟。如果仔细查看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上的这篇文章。
这里还有一篇简短但有趣的文章, 以简单明了的方式解释了依赖项注入。
此外, srcmini博客上还提供了有关单一责任原则和DI的精彩文章。
实例化RegistrationViewModel时, 它将在其构造函数中注入NetworkService协议实现(因此, 具有依赖关系注入原理的名称):
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
由于我们的视图模型类仅依赖于协议, 因此没有什么可以阻止我们创建自定义(或模拟的)NetworkService实现类并将模拟的类注入到我们的视图模型对象中。
让我们创建模拟的NetworkService协议实现。
右键单击项目导航器中的TestingIOSTests文件夹, 将新的Swift文件添加到我们的测试目标, 选择”新建文件”, 选择” Swift文件”, 并将其命名为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的测试期望添加到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")
再次运行测试以查看是否通过。你可以尝试在网络服务模拟实现中更改延迟时间, 以查看期望计时器用尽时会发生什么。
在没有回调的情况下使用异步调用测试方法
我们的示例项目方法tryUserRegistration使用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, 其中的tryRegistration方法将等待一秒钟, 然后返回成功的注册, 因此你可以使用Grand Central Dispatch(GCD), 并使用asyncAfter方法在一秒钟后检查你的断言。添加GCD的async之后, 我们的测试代码如下所示:
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方法中, 就像你要在其中编写一些内容一样。
- 按下”记录UI”测试按钮(屏幕底部的红色圆圈)。
- 按下”录制”按钮后, 将启动应用程序
- 启动应用程序后, 点击电子邮件文本字段并输入” [受电子邮件保护]”。你会注意到该代码自动出现在测试方法主体内。
你可以使用此功能记录所有UI指令, 但是你可能会发现手动编写简单的指令会更快。
这是一个记录器指令的示例, 用于点击密码文本字段并输入电子邮件地址” [受电子邮件保护]”
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元素, 可以使用占位符标识符。可以在”身份检查器”窗格中”辅助功能”下的情节提要中设置占位符标识符。将密码文本字段的可访问性标识符设置为” passwordTextField”。
密码用户界面的交互现在可以写成:
let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"]
passwordTextField.tap()
passwordTextField.typeText("password")
还剩下一个UI交互:确认密码输入交互。这次, 你将通过其占位符引用确认密码文本字段。转到情节提要, 然后在”确认密码”文本字段中添加”确认密码”占位符。用户交互现在可以这样写:
let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"]
confirmPasswordTextField.tap()
confirmPasswordTextField.typeText("password")
现在, 当你具有所有必需的UI交互时, 剩下的就是编写一个简单的XCTAssert(与单元测试中的操作相同)来验证”注册”按钮的isEnabled状态是否设置为true。注册按钮可以使用其标题进行引用。声明检查按钮的isEnabled属性如下所示:
let registerButton = XCUIApplication().buttons["REGISTER"]
XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")
现在, 整个UI测试应如下所示:
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")
}
如果运行测试, 则Xcode将启动模拟器并启动我们的测试应用程序。启动应用程序后, 我们的UI交互指令将一一运行, 最后, 断言将被成功断言。
为了改进测试, 我们还测试一下, 如果未正确输入任何必填字段, 则注册按钮的isEnabled属性为false。
完整的测试方法现在应如下所示:
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")
}
提示:识别UI元素的首选方法是使用可访问性标识符。如果使用名称, 占位符或其他可以本地化的属性, 则使用其他语言时将找不到该元素, 在这种情况下, 测试将失败。
UI测试示例非常简单, 但是它演示了自动UI测试的强大功能。
发现Xcode包含的UI测试框架的所有可能(并且有很多)的最好方法是开始在项目中编写UI测试。首先从简单的用户故事开始, 如所示, 然后慢慢过渡到更复杂的故事和测试。
通过编写良好的测试成为更好的开发人员
根据我的经验, 学习和尝试编写良好的测试将使你思考开发的其他方面。它将完全帮助你成为更好的iOS开发人员。
要编写好的测试, 你将必须学习如何更好地组织代码。
组织化, 模块化, 编写良好的代码是成功且轻松进行单元和UI测试的主要要求。
在某些情况下, 如果代码组织得不好, 甚至不可能编写测试。
在考虑应用程序结构和代码组织时, 你将意识到, 通过使用MVVM, MVP, VIPER或其他此类模式, 你的代码将具有更好的结构化, 模块化和易于测试的方式(还可以避免出现Massive View Controller问题) 。
毫无疑问, 在编写测试时, 你将不得不创建一个模拟类。它将使你思考和了解依赖项注入原理和面向协议的编码实践。了解并使用这些原则将显着提高你未来项目的代码质量。
一旦开始编写测试, 你可能会发现自己在编写代码时会更多地考虑拐角情况和边缘条件。这将帮助你在可能的错误变为错误之前消除它们。考虑到方法的可能问题和负面结果, 你不仅会测试正面结果, 而且还将开始测试负面结果。
如你所见, 单元测试可能会影响不同的开发方面, 并且通过编写良好的单元和UI测试, 你将有可能成为一个更好, 更快乐的开发人员(并且你不必花费很多时间来修复错误)。
开始编写自动化测试, 最终你将看到自动化测试的好处。当你亲自看到它时, 你将成为它的最强支持者。
评论前必须登录!
注册