编写测试并不迷人 (glamorous),但是既然测试能让你闪闪发光 (sparkling) 的应用程序避免变成 (from turning into) 一堆乱七八糟的垃圾,那么说明测试是必要的。如果你正在阅读这篇教程,那么你已经知道你 应该 为代码和 UI 编写测试,但是你可能不知道如何做。
也许你已经有一个 “可以运行” 的应用,但想测试你正在进行的扩展应用的更改。也许你已经编写了一些测试,但不确定它们是否是 正确 的测试。或者,你已经开始开发一个新应用,想要边开发边测试。
这篇教程将告诉你如何:
- 使用 Xcode 的测试导航器来测试应用的模型和异步方法
- 通过使用存根 (stubs) 和模拟对象 (mocks) 模拟与库或系统对象的交互
- 测试 UI 和性能
- 使用代码覆盖率工具
在此过程中,你将学到一些测试高手常用的专业术语。
开始
首先,下载教程素材。它包含一个基于 UIKit Apprentice 中的示例应用的 BullsEye 项目。这是一个简单的运气和机会游戏。游戏逻辑在 BullsEyeGame
类中,你将在本教程中对其进行测试。
测试什么
在编写任何测试之前,重要的是了解基础知识。你需要测试什么?
如果你的目标是扩展现有应用,你应该首先为计划更改的任何组件编写测试。
一般来说,测试应该覆盖:
- 核心功能:模型类和方法及其与控制器的交互
- 最常见的 UI 工作流
- 边界条件
- Bug 修复
测试的最佳实践
首字母缩写 FIRST 描述了有效单元测试的一套简明标准。这些标准是:
- Fast:测试应该快速运行。
- Independent/Isolated:测试之间不应共享状态。
- Repeatable:每次运行测试时,都应获得相同的结果。外部数据提供者或并发问题可能导致间歇性失败。
- Self-validating:测试应完全自动化。输出应该是"通过"或"失败",而不是依赖程序员对日志文件的解释。
- Timely:理想情况下,你应该在编写生产代码之前编写测试它们的测试。这被称为测试驱动开发。
遵循 FIRST 原则将使你的测试保持清晰有用,而不会成为应用开发的障碍。
Xcode 中的单元测试
测试导航器 提供了使用测试的最简单方法。你将使用它来创建测试目标并对你的应用运行测试。
创建单元测试目标
打开 BullsEye 项目并按下 Command-6 打开测试导航器。
点击左下角的 +,然后从菜单中选择 New Unit Test Target…:
接受默认名称 BullsEyeTests,并输入 com.raywenderlich 作为 Organization Identifier。当测试包出现在测试导航器中时,通过点击展开三角形展开它,然后点击 BullsEyeTests 在编辑器中打开它。
默认模板导入测试框架 XCTest,并定义了 BullsEyeTests
子类,其中包含 setUpWithError()
、tearDownWithError()
和示例测试方法。
你可以通过三种方式运行测试:
- Product ▸ Test 或 Command-U。这两种方式都会运行 所有 测试类。
- 点击测试导航器中的箭头按钮。
- 点击边栏中的菱形按钮。
你也可以通过点击测试导航器或边栏中的菱形来运行单个测试方法。
当所有测试成功时,菱形将变为绿色并显示勾号。点击 testPerformanceExample()
末尾的灰色菱形打开性能结果:
你不需要 testPerformanceExample()
或 testExample()
,所以删除它们。
使用 XCTAssert 测试模型
首先,你将使用 XCTAssert
函数测试 BullsEye 模型的核心功能:BullsEyeGame
是否正确计算一轮的分数?
在 BullsEyeTests.swift 中,在 import XCTest
下面添加这一行:
@testable import BullsEye
这使单元测试可以访问 BullsEye 中的 internal 类型和函数。
在 BullsEyeTests
顶部添加这个属性:
var sut: BullsEyeGame!
这为 BullsEyeGame
创建一个占位符,它是 System Under Test (SUT),或者说是这个测试用例类关注测试的对象。
接下来,用以下内容替换 setUpWithError()
的内容:
try super.setUpWithError()
sut = BullsEyeGame()
这在类级别创建 BullsEyeGame
,以便该测试类中的所有测试都可以访问 SUT 对象的属性和方法。
在你忘记之前,在 tearDownWithError()
中 释放 你的 SUT 对象。用以下内容替换它的内容:
sut = nil
try super.tearDownWithError()
注意:在
setUpWithError()
中创建 SUT 并在tearDownWithError()
中释放它是一种很好的做法,以确保每个测试都从干净的状态开始。
编写你的第一个测试
现在你已经准备好编写你的第一个测试了!
在 BullsEyeTests
末尾添加以下代码,测试当猜测值高于目标值时是否计算了预期的分数:
func testScoreIsComputedWhenGuessIsHigherThanTarget() {
// given
let guess = sut.targetValue + 5
// when
sut.check(guess: guess)
// then
XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}
测试方法的名称总是以 test 开头,后跟对其测试内容的描述。
将测试格式化为 given、when 和 then 部分是一种良好的做法:
- Given:在这里,你设置所需的任何值。在这个例子中,你创建一个
guess
值,以便可以指定它与targetValue
相差多少。 - When:在这一部分,你将执行被测试的代码:调用
check(guess:)
。 - Then:这是你断言你期望的结果的部分,如果测试 失败,会打印一条消息。在这种情况下,
sut.scoreRound
应该等于 95,因为它是 100 − 5。
通过点击边栏或测试导航器中的菱形图标运行测试。这将构建并运行应用,菱形图标将变成绿色勾号!你还会看到一个短暂显示在 Xcode 上方的弹出窗口,也表示成功:
注意:要查看 XCTestAssertions 的完整列表,请访问 Apple 的按类别列出的断言页面。
调试测试
让我们添加另一个测试来检查当猜测值低于目标值时的得分计算。添加以下测试方法:
func testScoreIsComputedWhenGuessIsLowerThanTarget() {
// given
let guess = sut.targetValue - 5
// when
sut.check(guess: guess)
// then
XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}
在这个测试中,猜测值比目标值小 5,所以分数应该仍然是 95。运行这个测试看看会发生什么。
如果这个测试失败了,那么你可能在 BullsEyeGame 中发现了一个 bug!在断点导航器中添加测试失败断点,这将在断言失败时停止测试运行。
运行你的测试:当测试失败时,它将在 XCTAssertEqual 行停止。在调试控制台中检查 sut 和 guess。
猜测值是目标值减 5,但 scoreRound 是 105 而不是 95!
要进一步调查,使用常规调试过程:在 when 语句处设置断点,并在 BullsEyeGame.swift 的 check(_:)
方法中设置另一个断点。然后再次运行测试,分步执行 let difference 行,检查 difference 值:
问题是 difference 是负数,所以得分是 100 - (-5)。要修复这个问题,应该使用 difference 的绝对值。
修改 BullsEyeGame.swift 中的 check 方法,将:
let difference = guess - targetValue
改为:
let difference = abs(guess - targetValue)
移除两个断点并再次运行测试以确认它现在通过了。
使用 XCTestExpectation 测试异步操作
许多 iOS 应用程序与网络、数据库或文件系统进行异步交互。这些异步操作需要特殊的测试处理,确保测试能够等待操作完成后再进行断言。
要测试异步操作,可以使用 XCTestExpectation
。它允许你创建一个期望(expectation),告诉测试等待满足这个期望,然后才能结束测试。
下面是测试异步操作的基本模式:
func testAsynchronousOperation() {
// 1. 创建期望
let expectation = expectation(description: "描述你期望发生的事")
// 2. 执行异步操作
performAsyncOperation {
// 3. 异步操作完成时,满足期望
expectation.fulfill()
}
// 4. 等待期望被满足,设置超时时间
wait(for: [expectation], timeout: 5.0)
// 5. 在异步操作完成后进行断言
}
这个模式可以帮助你测试各种异步操作,如网络请求、动画或定时器。
快速失败
有时,测试失败是如此明显,以至于没有理由继续执行测试的剩余部分。在这种情况下,你可以使用 XCTFail(_:file:line:)
强制测试立即失败:
func testWithCondition() {
guard someConditionIsMet else {
XCTFail("重要条件未满足")
return
}
// 继续测试
}
有条件地失败
你可能希望根据特定条件跳过某些测试。例如,某些测试可能只在特定的设备或操作系统版本上运行。在这些情况下,你可以使用 XCTSkip
来有条件地跳过测试:
func testFeatureOnlyAvailableIniOS14() throws {
if #available(iOS 14.0, *) {
// 测试 iOS 14 特性
} else {
throw XCTSkip("此测试需要 iOS 14")
}
}
模拟对象和交互
当你测试一个与其他对象交互的对象时,可能不希望测试涉及真实的依赖项。例如,如果测试需要网络请求,则测试将变得缓慢和不可靠。
这就是为什么我们使用存根(Stubs)和模拟对象(Mocks)来代替真实对象:
- 存根(Stub):提供测试所需的固定响应,不关心如何被调用
- 模拟对象(Mock):记录它们如何被调用,允许你验证交互方式
使用存根模拟输入
创建一个存根非常简单,你只需创建一个实现相同协议的对象,并提供固定的测试数据:
protocol DataProvider {
func fetchData(completion: @escaping (Data?, Error?) -> Void)
}
class StubDataProvider: DataProvider {
var stubData: Data?
var stubError: Error?
func fetchData(completion: @escaping (Data?, Error?) -> Void) {
completion(stubData, stubError)
}
}
现在你可以使用这个存根来测试你的代码如何处理不同的数据情况:
func testHandleDataFetchSuccess() {
// 创建存根并设置测试数据
let stub = StubDataProvider()
stub.stubData = "测试数据".data(using: .utf8)
// 将存根注入被测试系统
sut = SystemUnderTest(dataProvider: stub)
// 执行测试
sut.loadData()
// 断言
XCTAssertEqual(sut.loadedData, stub.stubData)
}
使用模拟对象验证交互
模拟对象除了返回数据外,还跟踪它如何被调用:
class MockDataProvider: DataProvider {
var fetchDataCallCount = 0
var stubbedData: Data?
func fetchData(completion: @escaping (Data?, Error?) -> Void) {
fetchDataCallCount += 1
completion(stubbedData, nil)
}
}
然后可以验证模拟对象是否按预期被调用:
func testDataProviderIsCalledOnRefresh() {
// 创建模拟对象
let mock = MockDataProvider()
// 注入模拟对象
sut = SystemUnderTest(dataProvider: mock)
// 在刷新时应该调用数据提供者
sut.refresh()
// 验证交互
XCTAssertEqual(mock.fetchDataCallCount, 1, "刷新应该调用一次数据提供者")
}
Xcode 中的 UI 测试
UI 测试模拟用户与应用程序界面的交互。这些测试非常有价值,因为它们验证整个应用程序从用户角度的工作方式。
创建 UI 测试目标
与单元测试类似,你可以从测试导航器创建 UI 测试目标,选择 “New UI Test Target…"。
UI 测试使用 XCUIApplication 类启动你的应用程序,并通过 XCUIElement 查询与 UI 元素交互:
func testExample() throws {
// 启动应用
let app = XCUIApplication()
app.launch()
// 与 UI 交互
app.buttons["增加"].tap()
// 验证 UI 状态
XCTAssertEqual(app.staticTexts["计数器"].label, "1")
}
记录 UI 测试
Xcode 提供了一个记录功能,可以自动生成 UI 测试代码。在编辑器中放置光标,然后点击记录按钮,与应用交互。Xcode 将生成相应的测试代码。
测试性能
除了测试功能正确性,Xcode 还支持性能测试,用于确保你的代码保持高效:
func testPerformanceExample() throws {
measure {
// 放置要测量性能的代码
for _ in 1...1000 {
_ = complexAlgorithm()
}
}
}
当你运行这个测试时,Xcode 会多次执行 measure
块内的代码,并报告平均执行时间。如果执行时间明显变慢,测试将失败。
启用代码覆盖率
代码覆盖率是衡量测试质量的指标,它显示了你的测试覆盖了多少代码。
启用代码覆盖率步骤:
- 编辑方案 (Edit Scheme)
- 选择 “Test” 操作
- 选择 “Options” 选项卡
- 勾选 “Code Coverage” 选项
运行测试后,可以在报告导航器中查看覆盖率报告。
追求 100% 覆盖率?
虽然高代码覆盖率是好事,但盲目追求 100% 的覆盖率可能适得其反。测试的目标是确保代码的正确性,而不仅仅是提高覆盖率数字。
关注测试以下方面:
- 容易出错的复杂逻辑
- 经常变化的代码
- 核心业务功能
- 已修复的 bug
总结
本教程介绍了如何在 Xcode 中进行单元测试和 UI 测试。掌握这些测试技术将帮助你创建更可靠、更健壮的 iOS 应用。
关键要点:
- 使用
XCTAssert
函数测试模型层 - 使用
XCTestExpectation
测试异步操作 - 使用存根和模拟对象隔离测试
- 创建 UI 测试验证用户交互
- 测量代码性能并监控变化
- 监控代码覆盖率,但关注测试质量而非数量
最重要的是,养成编写测试的习惯。随着时间的推移,这将为你的应用程序构建一个安全网,让你能够自信地进行更改并添加新功能。