iOS Unit Testing and UI Testing Tutorial

编写测试并不迷人 (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() 和示例测试方法。

你可以通过三种方式运行测试:

  1. Product ▸ TestCommand-U。这两种方式都会运行 所有 测试类。
  2. 点击测试导航器中的箭头按钮。
  3. 点击边栏中的菱形按钮。

运行测试

你也可以通过点击测试导航器或边栏中的菱形来运行单个测试方法。

当所有测试成功时,菱形将变为绿色并显示勾号。点击 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 开头,后跟对其测试内容的描述。

将测试格式化为 givenwhenthen 部分是一种良好的做法:

  1. Given:在这里,你设置所需的任何值。在这个例子中,你创建一个 guess 值,以便可以指定它与 targetValue 相差多少。
  2. When:在这一部分,你将执行被测试的代码:调用 check(guess:)
  3. 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 块内的代码,并报告平均执行时间。如果执行时间明显变慢,测试将失败。

启用代码覆盖率

代码覆盖率是衡量测试质量的指标,它显示了你的测试覆盖了多少代码。

启用代码覆盖率步骤:

  1. 编辑方案 (Edit Scheme)
  2. 选择 “Test” 操作
  3. 选择 “Options” 选项卡
  4. 勾选 “Code Coverage” 选项

运行测试后,可以在报告导航器中查看覆盖率报告。

追求 100% 覆盖率?

虽然高代码覆盖率是好事,但盲目追求 100% 的覆盖率可能适得其反。测试的目标是确保代码的正确性,而不仅仅是提高覆盖率数字。

关注测试以下方面:

  • 容易出错的复杂逻辑
  • 经常变化的代码
  • 核心业务功能
  • 已修复的 bug

总结

本教程介绍了如何在 Xcode 中进行单元测试和 UI 测试。掌握这些测试技术将帮助你创建更可靠、更健壮的 iOS 应用。

关键要点:

  • 使用 XCTAssert 函数测试模型层
  • 使用 XCTestExpectation 测试异步操作
  • 使用存根和模拟对象隔离测试
  • 创建 UI 测试验证用户交互
  • 测量代码性能并监控变化
  • 监控代码覆盖率,但关注测试质量而非数量

最重要的是,养成编写测试的习惯。随着时间的推移,这将为你的应用程序构建一个安全网,让你能够自信地进行更改并添加新功能。

参考资料