Swift-IOS-测试驱动开发第四版-全-

Swift IOS 测试驱动开发第四版(全)

原文:zh.annas-archive.org/md5/a3438258c14f40ff35162f094253bcef

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自动测试是敏捷软件开发的一个基本组成部分,尤其是单元测试,它们快速可靠的反馈帮助开发者保持项目的可维护性和清洁。测试驱动开发TDD)方法为开发者提供了关于如何构建可扩展、可维护的——因此是敏捷的——项目的明确规则。通常,一旦克服了对在编写代码之前编写测试的初始抵触,开发者就会坚持使用 TDD,因为他们觉得他们的代码更好,对结果更有信心。

在本书中,我们将探讨如何为 iOS 15+ 的许多不同方面编写单元测试,所有这些都是在测试驱动开发的环境中完成的。本书从对测试的一般介绍和 iOS 应用中的 TDD 开始,并在整本书中构建一个完整的应用。它涵盖了基本的 UI 结构、Combine 框架、async/await,甚至 SwiftUI。

在完成本书的学习并挑战自我练习后,你将能够为 iOS 开发中的各种代码编写测试,并且你将拥有决定下一步学习什么的工具,以成为测试驱动型 iOS 开发专家。

本书面向的对象

TDD 是一种经过验证的早期发现软件缺陷的方法。在编写代码之前编写测试可以提高你应用的架构和可维护性。本书将指导你使用 TDD 创建完整应用,并涵盖 iOS 应用的核心元素:视图控制器、视图、导航、网络、Combine 和 SwiftUI。

如果你已经制作了你的第一个小型 iOS 应用,并想学习如何使用自动化单元测试来改进你的工作,那么这本书就是为你准备的。

本书涵盖的内容

第一章你的第一个单元测试,展示了第一个单元测试的工作情况。我们为虚构的博客应用编写真实测试,并探索了 XCTest 测试框架中的不同类型的断言,XCTest 是苹果公司的一个测试框架。

第二章理解测试驱动开发,探讨了测试驱动开发以及它如何帮助我们开发者编写可维护的代码。

第三章在 Xcode 中进行测试驱动开发,将前两章的见解结合起来,并探讨测试驱动开发在 Xcode 中的工作方式。你将学习一些技巧和配置,使 Xcode 成为一个有价值的测试工具。

第四章我们将要构建的应用,讨论了本书剩余部分将要构建的应用。这一章以在 Xcode 中设置应用项目结束。

第五章构建待办事项的结构,展示了如何构建我们应用的模型层。通过在此处工作,你将学习如何为 Combine 代码编写测试。

第六章, 测试、加载数据和保存数据,讨论了应用程序中使用的数据需要在 iOS 设备的文件系统中保存和加载的事实。在这一章中,我们构建了负责这个任务的类。

第七章, 为待办事项构建表格视图控制器,展示了如何为具有可变数据源的表格视图编写测试。你将学习如何测试表格视图单元格的更新和单元格的选择。

第八章, 构建简单的详情视图,探讨了如何测试用户界面元素,如标签、按钮和地图。我们还看看如何测试用户操作,这些操作会改变模型层中的数据。

第九章, 使用 SwiftUI 的测试驱动输入视图,展示了如何构建和测试使用 SwiftUI 创建的视图。为了能够测试 SwiftUI 代码,我们将第三方测试库添加到测试目标中。

第十章, 测试网络代码,探讨了为 URLSession 的新 async/await API 编写测试。这将允许你编写干净的测试,使用快速模拟对象模拟网络通信。

第十一章, 使用协调器轻松导航,最后一章展示了如何为我们的应用程序视图控制器之间的导航编写测试。这最终使我们能够在模拟器上看到我们的小型应用程序运行。我们使用 TDD 修复最后一个错误,并最终得到一个可工作的应用程序。

为了充分利用这本书

你需要在你的 Mac 上安装最新的 Xcode 版本。本书中的代码已在 Xcode 13 和 Swift 5.5 上测试过,但它也应该与 Xcode 和 Swift 的新版本兼容。

如果你使用的是这本书的数字版,我们建议你亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助你避免与代码复制和粘贴相关的任何潜在错误。

你应该尝试完成书中的所有练习。它们旨在为你提供更多见解并加强你的经验。

下载示例代码文件

你可以从 GitHub(github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:http://static.packt-cdn.com/downloads/9781803232485_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“一个表格视图通常由UITableViewController表示,它也是表格视图的数据源和代理。”

代码块设置如下:

// APIClient.swift
lazy var geoCoder: GeoCoderProtocol
  = CLGeocoder()

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

mkdir src/client/apollo touch src/client/apollo/index.js

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从详细信息屏幕,用户将能够检查一个项目。”

小贴士或重要提示

看起来像这样。

联系我们

读者的反馈总是受欢迎的。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《使用 Swift 进行测试驱动 iOS 开发》第四版,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一部分 – 测试驱动 iOS 开发的基础

没有对基础知识的好理解,学习就会变得困难且令人沮丧。在本节中,我们将学习单元测试是什么,它们如何与测试驱动开发相联系,以及它们在 Xcode 中的外观和工作方式。

在本节中,我们将涵盖以下章节:

  • 第一章, 你的第一个单元测试

  • 第二章, 理解测试驱动开发

  • 第三章, Xcode 中的测试驱动开发

第一章:第一章:你的第一个单元测试

当 iPhone 平台首次推出时,应用程序很小,只关注一个功能。制作一个只做一件事的应用程序(例如,只显示白色屏幕的手电筒应用程序)很容易赚钱。这些早期应用程序的代码只有几百行,可以通过在屏幕上轻敲几分钟轻松测试。

从那时起,App Store 和可用的应用程序已经发生了很大变化。App Store 中仍然有一些专注于明确功能的较小应用程序,但从中赚钱变得困难得多。一个常见的应用程序具有许多功能,但仍需要易于使用。有一些公司有多个开发者全职工作在一个应用程序上。这些应用程序有时具有通常在桌面应用程序中找到的功能集。手动测试这些应用程序的所有功能非常困难且耗时。对于每次更新,都需要手动测试所有功能。

这其中的一个原因是手动测试需要通过用户界面UI)进行,加载要测试的应用程序需要时间。除此之外,与计算机在测试和验证计算机程序等任务上的能力相比,人类非常慢。大多数时候,计算机(或智能手机)都在等待用户的下一个输入。如果我们能让计算机插入值,测试可以大大加速。事实上,计算机可以在几秒钟内运行几百次测试。这正是单元测试的全部内容。

单元测试是一段执行其他代码并检查结果是否符合开发者预期的代码。单词“单元”意味着测试执行一小块代码。通常,那是一个类的一个函数或类似类型的结构。单元实际上有多大取决于要测试的功能和编写测试的人。

起初编写单元测试似乎很难,因为对大多数开发者来说,这是一个新的概念。本章旨在帮助你开始编写你的第一个简单的单元测试。

本章我们将涵盖的主要主题包括:

  • 构建你的第一个自动单元测试

  • XCTest框架中的断言函数

  • 理解与其他测试类型的区别

技术要求

本章中所有的代码都已上传(完整形式)在此:

github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter01

构建你的第一个自动单元测试

如果你已经进行了一些 iOS 开发(或一般的应用程序开发),以下示例可能对你来说很熟悉。

你正在计划构建一个应用程序。你开始收集功能,绘制一些草图,或者你的项目经理将需求交给你。在某个时候,你开始编码。你设置项目并开始实现应用程序所需的功能。

假设该应用有一个输入表单,用户输入的值在数据发送到服务器之前必须经过验证。验证检查,例如,电子邮件地址和电话号码是否有有效的格式。在实现表单后,你想要检查一切是否正常工作。但在你可以手动测试之前,你需要编写代码在屏幕上显示表单。然后,你在 iOS 模拟器中构建并运行你的应用。表单在视图层次结构的深处,所以你导航到视图并将值放入表单。它不起作用——电话号码验证代码有问题。你回到代码中并尝试修复问题。有时,这也意味着启动调试器并逐步通过代码来找到错误。

最终,验证将适用于你放入的测试数据。通常,你需要测试所有可能的值以确保验证不仅适用于你的姓名和你的数据,而且适用于所有有效数据。但是,你桌上有这么长的需求列表,而你已经开始晚了。在模拟器中导航到表单需要三次点击,输入所有不同的值需要太长时间。毕竟,你是一名程序员。

如果有一个机器人能为你进行这项测试就好了。

单元测试是什么?

自动单元测试就像这个机器人为你做的那样。它们执行代码,但不需要导航到要测试的功能屏幕。而不是一次又一次地运行应用,你用不同的输入数据编写测试,让计算机在眨眼间测试你的代码。让我们看看在简单示例中它是如何工作的。

实现单元测试示例

在这个例子中,我们编写了一个简单的函数,用于计算字符串中的元音数量。按照以下步骤进行:

  1. 打开 Xcode 并转到文件 | 新建 | 项目

  2. 导航到iOS | 应用程序 | App并点击下一步

  3. 输入名称FirstDemo,选择语言字段中的Swift,并勾选包含测试

  4. 取消选择使用 Core Data并点击下一步。以下截图显示了 Xcode 中的选项:

图 1.1 – 设置你的新项目

图 1.1 – 设置你的新项目

Xcode 设置了一个用于开发的项目,此外还为你设置了两个单元测试和 UI 测试的目标。

  1. 在项目导航器中打开FirstDemoTests文件夹。在文件夹内,有一个文件:FirstDemoTests.swift

  2. 选择FirstDemoTests.swift以在编辑器中打开它。

你在这里看到的是一个测试用例。测试用例是一个包含几个测试的类。一开始,为每个主要目标类创建一个测试用例是一个好习惯。

让我们一步一步地查看这个文件,如下所示:

文件从导入测试框架和主要目标开始,如下所示:

import XCTest
@testable import FirstDemo

每个测试用例都需要导入XCTest框架。它定义了XCTestCase类以及你将在本章后面看到的测试断言。

第二行导入了FirstDemo模块。你为演示应用编写的所有代码都将在这个模块中。默认情况下,类、结构体、枚举及其方法使用内部访问控制定义。这意味着它们只能从模块内部访问。但是测试代码存在于模块之外。为了能够编写测试代码,你需要使用@testable关键字导入模块。这个关键字使得模块的内部元素在测试用例中可访问。

接下来,我们将查看类声明,如下所示:

class FirstDemoTests: XCTestCase {

这里没有特别之处。这定义了FirstDemoTests类为XCTestCase的子类。

类中的前两种方法在以下代码片段中显示:

override func setUpWithError() throws {
  // Put setup code here. This method ...
}
override func tearDownWithError() throws {
  // Put teardown code here. This method ...
}

setUpWithError()方法在类中每个测试方法调用之前被调用。在这里,你可以插入应该在每次测试之前运行的代码。你将在本章后面的例子中看到这个例子。

setUpWithError()的对立面是tearDownWithError()。这个方法在类中每个测试方法调用之后被调用。如果你需要在测试后进行清理,请将必要的代码放在这个方法中。

下面的两个方法是苹果模板作者提供的模板测试:

func testExample() throws {
  // This is an example of a functional test case.
  // Use XCTAssert and related functions to ...
}
func testPerformanceExample() throws {
  // This is an example of a performance test case.
  self.measure {
    // Put the code you want to measure the time of here.
  }
}

第一种方法是常规单元测试。在这本书的过程中,你将大量使用这种测试。

第二种方法是性能测试。它用于测试执行时间关键计算的方法或函数。你放入度量闭包中的代码被调用 10 次,并测量平均持续时间。性能测试在实现或改进复杂算法时非常有用,并确保它们的性能不会下降。我们在这本书中不会使用性能测试。

你编写的所有测试方法都必须有test前缀;否则,测试运行器找不到并运行它们。这种行为允许轻松禁用测试——只需移除方法名称中的test前缀。稍后,你将了解其他在不重命名或删除的情况下禁用一些测试的可能性。

现在,让我们实现我们的第一个测试。假设你有一个计算字符串中元音的方法。一个可能的实现如下:

func numberOfVowels(in string: String) -> Int {
  let vowels: [Character] = ["a", "e", "i", "o", "u",
                             "A", "E", "I", "O", "U"]
  var numberOfVowels = 0
  for character in string {
    if vowels.contains(character) {
      numberOfVowels += 1
    }
  }
  return numberOfVowels
}

我猜这段代码让你感到不舒服。请保持冷静。不要把这本书扔到角落里——我们很快就会让这段代码变得更加“Swift 式”。将此方法添加到ViewController类中的ViewController.swift

此方法执行以下操作:

  1. 首先,定义了一个包含英语字母表中所有元音的字符数组。

  2. 接下来,我们定义一个变量来存储元音的数量。计数是通过遍历字符串中的字符来完成的。如果当前字符包含在vowels数组中,numberOfVowels就增加一。

  3. 最后,返回numberOfVowels

打开 FirstDemoTests.swift 并删除带有 test 前缀的方法。然后,添加以下方法:

func test_numberOfVowels_whenGivenDominik_shouldReturn3() {
  let viewController = ViewController()
  let result = viewController.numberOfVowels(in: "Dominik")
  XCTAssertEqual(result, 3,
    "Expected 3 vowels in 'Dominik' but got \(result)")
}

这个测试创建了一个 ViewController 实例并将其分配给 viewController 常量。它调用我们想要测试的函数并将结果分配给一个常量。最后,测试方法中的代码调用 XCTAssertEqual(_:, _:) 函数来检查结果是否是我们预期的。如果 XCTAssertEqual 中的前两个参数相等,则测试通过;否则,它失败。

要运行测试,选择您选择的模拟器,然后转到产品 | 测试,或使用 U 快捷键。Xcode 编译项目并运行测试。您将看到类似以下内容:

![图 1.2 – Xcode 在测试通过时显示带有勾选标记的绿色菱形图片 1.02 - B18127

图 1.2 – Xcode 在测试通过时显示带有勾选标记的绿色菱形

编辑器左侧的带有勾选标记的绿色菱形表示测试通过。所以,这就完成了——你的第一个单元测试。退后一步,庆祝一下。这可能对你来说是一个新的开发范式的开始。

现在我们有一个快速的测试,证明了 numberOfVowels(in:) 方法确实做了我们想要的事情,我们将改进实现。这个方法看起来像是被从 ViewController.swift 转换过来的,并用这个更“Swift 式”的实现替换了 numberOfVowels(in:) 方法:

func numberOfVowels(in string: String) -> Int {
  let vowels: [Character] = ["a", "e", "i", "o", "u",
                             "A", "E", "I", "O", "U"]
  return string.reduce(0) {
    $0 + (vowels.contains($1) ? 1 : 0)
  }
}

在这里,我们使用了定义在数组类型上的 reduce 函数。reduce 函数使用提供的闭包将序列的所有元素组合成一个值。$0$1 是代表组合的当前值和序列中的下一个项目的匿名简写参数。再次运行测试 (U) 以确保这个实现与之前的实现工作相同。

禁用缓慢的 UI 测试

你可能已经意识到 Xcode 也会在 FirstDemoUITests 目标中运行 UI 测试。UI 测试非常慢。我们不希望在每次输入 U 快捷键时都运行这些测试。要禁用 UI 测试,请按照以下步骤操作:

  1. 打开方案选择并点击编辑方案…,如下截图所示:

![图 1.3 – 选择目标选择器以打开方案编辑器图片 1.03 - B18127

![图 1.3 – 选择目标选择器以打开方案编辑器 1. Xcode 打开方案编辑器。选择 FirstDemoUITests 目标,如下截图所示:![图 1.4 – 取消选择 UI 测试目标图片 1.04 - B18127

![图 1.4 – 取消选择 UI 测试目标这将禁用此方案的 UI 测试,运行测试变得快速。检查自己并使用 U 快捷键运行测试。

在我们继续之前,让我们回顾一下到目前为止我们所看到的内容。首先,你了解到你可以轻松编写测试代码来测试你的代码。其次,你看到测试有助于改进代码,因为现在,你不必担心在更改实现时破坏功能。

为了检查方法的结果是否符合我们的预期,我们使用了XCTAssertEqual(_:, _:)。这是在 XCTest 框架中定义的许多XCTAssert函数之一。下一节将展示最重要的几个。

XCTest 框架中的断言函数

每个测试都需要断言一些预期的行为。使用XCTAssert函数告诉 Xcode 预期的内容。

一个没有XCTAssert函数且不抛出错误的测试方法将始终通过。

最重要的断言函数列在这里:

  • XCTAssertTrue(_:_:file:line:): 这条断言表示一个表达式是真的。

  • XCTAssert(_:_:file:line:): 这条断言与XCTAssertTrue(_:_:file:line:)相同。

  • XCTAssertFalse(_:_:file:line:): 这条断言表示一个表达式是假的。

  • XCTAssertEqual(_:_:_:file:line:): 这条断言表示两个表达式相等。

  • XCTAssertEqual(_:_:accuracy:_:file:line:): 这条断言表示两个表达式相同,考虑到accuracy参数中定义的精度。

  • XCTAssertNotEqual(_:_:_:file:line:): 这条断言表示两个表达式不相等。

  • XCTAssertNotEqual(_:_:accuracy:_:file:line:): 这条断言表示两个表达式不相同,考虑到accuracy参数中定义的精度。

  • XCTAssertNil(_:_:file:line:): 这条断言表示一个表达式是 nil。

  • XCTAssertNotNil(_:_:file:line:): 这条断言表示一个表达式不是 nil。

  • XCTFail(_:file:line:): 这条断言总是失败。

要查看可用的XCTAssert函数的完整列表,请按Ctrl并点击您刚刚编写的测试中的XCTAssertEqual单词。然后,在弹出菜单中选择跳转到定义,如图下所示:

图 1.5 – 跳转到所选函数的定义

图 1.5 – 跳转到所选函数的定义

注意,大多数XCTAssert函数都可以用XCTAssert(_:_:file:line)替换。例如,以下断言函数断言的是同一件事情:

// This assertion asserts the same as...
XCTAssertEqual(2, 1+1, "2 should be the same as 1+1")
// ...this assertion
XCTAssertTrue(2 == 1+1, "2 should be the same as 1+1")

但你应该尽可能使用更精确的断言,因为更精确的断言方法的日志输出会告诉你失败时确切发生了什么。例如,看看以下两个断言的日志输出:

XCTAssertEqual(1, 2)
// Log output:
// XCTAssertEqual failed: ("1") is not equal to ("2")
XCTAssert(1 == 2)
// Log output:
// XCTAssertTrue failed

在第一种情况下,你不需要查看测试就能理解发生了什么。日志会告诉你确切出了什么问题。

自定义断言函数

但有时,即使是更精确的断言函数也可能不够精确。在这种情况下,你可以编写自己的断言函数。例如,假设我们有一个断言两个字典具有相同内容的测试。如果我们使用 XCTAssertEqual 来测试,日志输出将如下所示:

func test_dictsAreQual() {
  let dict1 = ["id": "2", "name": "foo"]
  let dict2 = ["id": "2", "name": "fo"]
  XCTAssertEqual(dict1, dict2)
  // Log output:
  // XCTAssertEqual failed: ("["name": "foo", "id":
    "2"]")...
  // ...is not equal to ("["name": "fo", "id": "2"]")
}

对于本例中的短字典,找到差异相当容易。但如果字典有 20 个条目甚至更多呢?当我们向测试目标添加以下断言函数时,我们得到更好的日志输出:

func DDHAssertEqual<A: Equatable, B: Equatable>
  (_ first: [A:B],
   _ second: [A:B]) {
  if first == second {
    return
  }
  for key in first.keys {
    if first[key] != second[key] {
      let value1 = String(describing: first[key]!)
      let value2 = String(describing: second[key]!)
      let keyValue1 = "\"\(key)\": \(value1)"
      let keyValue2 = "\"\(key)\": \(value2)"
      let message = "\(keyValue1) is not equal to
        \(keyValue2)"
      XCTFail(message)
      return
    }
  }
}

此方法比较每个键的值,如果其中一个值不同则失败。此外,此断言函数还应检查字典是否具有相同的键。此功能留给读者作为练习。在这里,我们专注于如何编写自定义断言函数。通过保持示例简短,主要观点更容易理解。

当我们使用前面的字典运行此测试时,我们在 Xcode 中看到以下输出:

图 1.6 – Xcode 显示两个不同位置的失败

图 1.6 – Xcode 显示两个不同位置的失败

如前一个截图所示,Xcode 在断言函数中显示测试失败。在测试方法中,它只显示对失败的引用。幸运的是,有一个简单的解决方案。我们只需要将 fileline 参数传递给自定义断言函数,并在 XCTFail 调用中使用这些参数,如下所示:

 func DDHAssertEqual<A: Equatable, B: Equatable>(
  _ first: [A:B],
  _ second: [A:B],
  file: StaticString = #filePath,        // << new
  line: UInt = #line) {                  // << new
    if first == second {
      return
    }
    for key in first.keys {
      if first[key] != second[key] {
        let value1 = String(describing: first[key]!)
        let value2 = String(describing: second[key]!)
        let keyValue1 = "\"\(key)\": \(value1)"
        let keyValue2 = "\"\(key)\": \(value2)"
        let message = "\(keyValue1) is not equal to
          \(keyValue2)"
        XCTFail(message, file: file, line: line)  // << new
        return
      }
    }
  }

注意,我们的断言函数现在有两个新的参数:fileline,分别具有默认值 #filePath#line。当在测试方法中调用该函数时,这些默认参数确保将调用点的文件路径和行传递给该断言函数。然后,这些参数被转发到 XCTAssert 函数(在我们的情况下是 XCTFail,但这适用于所有 XCT... 函数)。因此,失败现在显示在调用 DDHAssertEqual 函数的行中,我们不需要更改断言函数的调用。以下截图说明了这一点:

图 1.7 – 改进的失败报告

本例展示了编写自己的断言函数,使其行为类似于 Xcode 中的函数是多么容易。自定义断言函数可以提高测试代码的可读性,但请记住,这也是你必须维护的代码。

理解与其他类型测试的区别

单元测试是良好测试套件的一部分。在我看来,它们是最重要的测试,因为当正确执行时,它们是快速、专注且易于理解的。但为了增加你对代码的信心,你还应该添加集成、UI/快照和手动测试。那些是什么?

集成测试

在集成测试中,正在测试的功能没有从其余代码中隔离出来。使用这些类型的测试,开发者试图弄清楚不同的单元(这些单元已经通过单元测试彻底测试)是否按要求相互交互。因此,集成测试执行真实的数据库查询并从实时服务器获取数据,这使得它们比单元测试慢得多。它们不像单元测试那样经常运行,并且失败更难以理解,因为错误必须在所有涉及的代码单元中追踪。

UI 测试

如其名所示,UI 测试在应用的 UI 上运行。一个计算机程序(测试运行器)以用户的方式执行应用。通常,这意味着在这种测试断言中,我们也必须使用屏幕上可访问的信息。这意味着 UI 测试只能测试当结果在屏幕上可见时,功能是否按预期工作。此外,这些测试通常相当慢,因为测试运行器通常必须等待动画和屏幕更新完成。

快照测试

快照测试比较 UI 与之前拍摄的快照。如果定义的像素百分比与快照图像不同,则测试失败。这使得它们非常适合在某个应用屏幕的 UI 已经完成,并且你想要确保它不会在给定的测试数据中发生变化的情况。

手动测试

在应用开发中,最后的测试类型是手动测试。即使你有数百个单元和集成测试,真实用户使用你的应用时很可能会发现一个错误。为了最小化用户可以发现的错误数量,你需要在团队中拥有测试人员或要求一些用户对你的应用测试版提供反馈。测试人员群体越多样化,他们在你的应用发货前发现的错误就越多。

在这本书中,我们只涵盖单元测试,因为测试驱动开发TDD)只有在快速可靠的反馈下才能合理地工作。

摘要

在本章中,我们讨论了单元测试是什么,并看到了一些简单的单元测试实例。我们了解了在 Apple 提供的测试框架XCTest中可用的不同断言函数。通过编写我们自己的断言函数,我们学会了如何改进日志输出以及需要做什么才能使其表现得像内置函数。本章以其他类型的测试以及它们与单元测试的不同之处结束。

在下一章中,我们将学习什么是 TDD(测试驱动开发),以及它的优缺点。

练习

  1. 编写一个断言函数,该函数仅在两个字典中的键不同时失败。如果其中一个字典中缺少键,该断言函数也应失败。

  2. 改进DDHAssertEqual<A: Equatable, B: Equatable>(_:_:file:line:),使其也检查字典的键。

第二章:第二章:理解测试驱动开发

现在我们已经了解了单元测试是什么以及它们如何有助于开发,我们将学习测试驱动开发TDD)。

在向您介绍 TDD 的起源和目标之后,我们将继续前进,看看它的利弊。到本章结束时,你将清楚地了解 TDD 的相关性以及可以使用它测试什么。

本章我们将涵盖的主要内容包括:

  • TDD 的起源

  • TDD 工作流程

  • TDD 的优势

  • TDD 的缺点

  • 要测试什么

TDD 的起源

1996 年,Kent Beck、Ward Cunningham 和 Ron Jeffries 在克莱斯勒公司进行综合薪酬系统项目时,引入了一种新的软件开发方法论,称为极限编程。单词极限表明,极限编程背后的概念与当时软件开发的观念完全不同。对于许多人来说,这些概念即使在今天听起来也有些极端。

该方法论基于 12 条规则或实践。其中一条规则指出,开发者必须编写单元测试,并且软件的所有部分都必须经过彻底的测试。在软件(或新功能)可以发布给客户之前,所有测试都必须通过。测试应该在它们所测试的生产代码之前编写。

这种所谓的“测试驱动编程”导致了 TDD。正如其名所示,在 TDD 中,测试驱动开发。这意味着开发者只有在有失败的测试时才会编写代码。测试决定了代码是否需要编写,它们还提供了一个衡量标准,以确定一个功能是否“完成”——当所有该功能的测试都通过时,它就算完成了。

如果你之前没有做过任何 TDD,这可能会让你觉得有些荒谬。你必须尝试并坚持一段时间,以看到其优势,并认识到它并不荒谬,而是一种相当聪明的做法。在 TDD 中,你总是专注于你正在构建的产品的一个功能。由于你有所有之前构建的功能的测试,你不必记住其余代码的细节。你可以信任现有的测试,并且不会破坏之前已经工作的东西。

由于每次只关注一个功能,你几乎总是会有一个可以工作的软件片段。所以,当你的老板走进你的办公室并要求你展示项目的当前状态时,你只需几分钟就能展示一个可展示的(即编译的)并且经过彻底测试的软件片段。

现在我们已经知道了 TDD 的含义,让我们来看看其工作流程。

TDD 工作流程 – 红色、绿色、重构

TDD 的正常工作流程包括三个步骤 – 红色绿色重构。以下各节将详细描述这些步骤。

红色

你从编写一个失败的测试开始。它需要测试软件产品中尚未实现的功能或你想要确保覆盖的边缘情况。红色这个名字来源于大多数 IDE 指示失败测试的方式。Xcode 使用带有白色 x 的红色菱形,如下图所示:

图 2.1 – Xcode 在红色菱形中用白色叉号标记失败测试

图 2.1 – Xcode 在红色菱形中用白色叉号标记失败测试

在这个步骤中编写的测试最初失败非常重要。否则,你无法确保测试正常工作并真正测试你想要实现的功能。可能你编写了一个总是通过且因此无用的测试。或者,可能该功能已经实现。无论如何,你都能对你的代码有更深入的了解。

绿色

在绿色步骤中,你编写最简单的代码以使测试通过。你编写的代码是否良好和整洁并不重要。代码也可以很愚蠢,甚至错误。只要所有测试都通过就足够了。绿色这个名字指的是大多数 IDE 如何指示通过测试。Xcode 使用带有白色勾号的绿色菱形。

图 2.2 – Xcode 在绿色菱形中用白色勾号标记通过测试

图 2.2 – Xcode 在绿色菱形中用白色勾号标记通过测试

尝试编写最简单的代码以使测试通过非常重要。通过这样做,你只编写你真正需要的代码,这是可能的最简单实现。当我提到简单时,我的意思是它应该易于阅读、理解和修改。测试代码应该始终易于理解。尝试编写你的测试,即使你已经几个月没有工作过,你也能理解它们。当测试失败时,通常是在你做完全不同的事情时。清晰易懂的测试帮助你快速找到问题并回到你正在工作的上下文中。

通常,最简单的实现可能不足以实现你试图实现的功能,但仍然足以使所有测试通过。这仅仅意味着你需要另一个失败的测试来进一步推动该功能的开发。

重构

在绿色步骤中,你只需编写足够的代码再次使所有测试通过。正如我刚才提到的,绿色步骤中的代码看起来如何并不重要。在重构步骤中,你改进代码。你移除重复,提取公共值,等等。做你需要做的事情,使代码尽可能好。测试帮助你避免在重构过程中破坏已实现的功能。

重要提示

不要跳过这个步骤。始终尝试思考在实现功能后如何改进代码。这样做有助于保持代码的整洁和可维护性。这确保了它始终保持良好的状态。

由于自上次重构步骤以来你只编写了几行代码,因此使代码变得整洁所需的变化不应花费太多时间。

TDD 的优点

TDD 具有优点和缺点。以下是主要优点:

  • 只编写所需的代码:当所有测试通过且你无法再想出另一个测试来编写时,你应该停止编写生产代码。如果你的项目需要另一个功能,你需要一个测试来驱动该功能的实现。你编写的代码是最简单的代码。因此,最终产品中的所有代码实际上都是实现功能所需的。

  • 更模块化的设计:在 TDD 中,你一次专注于一个微功能。由于你首先编写测试,代码自然会变得易于测试。易于测试的代码具有清晰的接口。这导致你的应用程序具有模块化设计。

  • 更容易维护:由于你的应用程序的不同部分彼此解耦并且具有清晰的接口,代码变得更容易维护。你可以用更好的实现来替换微功能的实现,而不会影响另一个模块。你甚至可以保留测试并重写整个应用程序。当所有测试通过时,你就完成了。

  • 更容易重构:每个功能都经过彻底测试。你不必害怕做出重大改变,因为如果所有测试仍然通过,一切都会好。这一点非常重要,因为作为开发者,你每天都在提高自己的技能。如果你在六个月后重新打开项目,你很可能会有很多改进代码的想法。但你对所有不同部分以及它们如何组合的记忆可能已经不再新鲜。因此,做出改变可能是危险的。有了完整的测试套件,你可以轻松地改进代码,而不用担心破坏你的应用程序。

  • 高测试覆盖率:每个功能都有一个测试。这导致高测试覆盖率。高测试覆盖率有助于你对自己的代码建立信心。

  • 测试记录了代码:测试代码显示了你的代码应该如何使用。因此,它记录了你的代码。测试代码是示例代码,显示了代码的功能以及接口应该如何使用。

  • 更少的调试:你有多少次浪费一整天来寻找一个讨厌的虫子?你有多少次从 Xcode 复制错误信息并在互联网上搜索它?在 TDD 中,你编写的错误更少,因为测试会尽早告诉你是否犯了错误。你编写的错误发现得也早。当你的记忆仍然清晰关于代码应该做什么以及它是如何做的时,你可以专注于修复错误。

在下一节中,我们将讨论 TDD 的缺点。

TDD 的缺点

正如世界上的一切事物一样,TDD 也有一些缺点。主要缺点如下:

  • 没有银弹:测试有助于发现错误,但它们不能发现你在测试代码和实现代码中引入的所有错误。如果你没有理解你需要解决的问题,编写测试很可能会帮不上忙。

  • 一开始似乎更慢:当你开始使用 TDD 时,你会感觉到实现简单的功能需要更长的时间。在最终开始编写代码之前,你需要思考接口、编写测试代码并运行测试。

  • 团队中的所有成员都需要这样做:由于 TDD 影响代码的设计,建议团队的所有成员都使用 TDD,或者根本不使用。此外,有时很难向管理层证明 TDD 的合理性,因为他们常常有一种感觉,即如果开发者编写的代码有一半的时间不会最终进入产品,那么实现新功能会花费更长的时间。如果整个团队都同意单元测试的重要性,这会有所帮助。

  • 当需求变化时,测试需要维护:可能反对 TDD 的最有力的论据是,测试的维护工作就像代码的维护工作一样。每当需求发生变化时,你需要更改代码和测试。但是你正在使用 TDD。这意味着你需要首先更改测试,然后让测试通过。所以,实际上,测试帮助你理解新的需求,并在不破坏其他功能的情况下实现代码。

初学者经常询问他们应该为代码的哪一部分编写测试。接下来的部分试图找到这个问题的答案。

要测试什么

应该测试什么?在使用 TDD 并遵循其理念时,答案很简单——一切。你只编写生产代码,因为有一个失败的测试。

在实践中,这并不那么容易。例如,按钮的位置和颜色是否需要测试?视图层次结构是否需要测试?可能不需要;按钮的颜色和确切位置对于应用程序的功能并不重要。在开发的早期阶段,这类事情往往会发生变化。随着自动布局、不同的屏幕尺寸和应用程序的不同本地化,按钮和标签的确切位置取决于许多参数。

通常,你应该测试那些使应用程序对用户有用的功能和那些需要工作的功能。用户并不关心按钮是否正好位于屏幕最右侧边缘 20 点处。用户寻求的是易于理解且使用愉快的应用程序。

除了这个之外,你不应该一次性使用单元测试来测试整个应用。单元测试的目的是测试计算的小单元。它们需要快速且可靠。像数据库访问和网络这样的东西应该使用集成测试来测试,在集成测试中,测试驱动着真正的完成应用。集成测试可以允许运行得慢,因为它们比单元测试运行得少得多。通常,它们会在服务器上每晚通过持续集成系统运行,而完整的测试套件需要几分钟(甚至几个小时)来执行并不重要。

摘要

在本章中,我们初步涉入了 TDD 的新领域。本章展示了 TDD 的工作流程——红、绿、重构——我们将在这本书中用它来构建应用。此外,我们还看到了 TDD 的优点和缺点。

在接下来的章节中,我们将探讨在 Xcode 中如何进行 TDD,这是我们大多数人用来构建 iOS 应用的工具。

第三章:第三章:Xcode 中的测试驱动开发

对于测试驱动开发TDD),我们需要一种编写和执行单元测试的方法。我们可以将测试写入 Xcode 项目的目标中,但这不太实际。我们必须以某种方式将测试代码与生产代码分开,并且我们需要编写一些脚本,这些脚本将执行测试代码并收集关于测试结果的反馈。

幸运的是,这一切都已经完成了。这一切始于 1998 年,当时瑞士公司 Sen:te 开发了 OCUnit,这是一个 Objective-C(因此有 OC 前缀)的测试框架。OCUnit 是 SUnit 的移植,SUnit 是 Kent Beck 在 1994 年为 Smalltalk 编写的测试框架。

在 Xcode 2.1 中,苹果公司将 OCUnit 添加到 Xcode 中。这一步骤的一个原因是他们在开发 Tiger 操作系统的同时开发了 Core Data,Core Data 就是与 Tiger 一起发布的。苹果工程师 Bill Bumgarner 在后来的博客文章中写道:

“Core Data 1.0 并不完美,但它是一个坚如磐石的产品,我为之感到无比自豪。所达到的质量和性能没有使用单元测试是无法实现的。此外,我们能够在开发周期的后期对代码库进行高度破坏性的操作。最终结果是性能大幅提升,代码库更加整洁,发布更加稳定。”

苹果意识到,在变化的环境中开发复杂系统时,单元测试是多么有价值。他们希望第三方开发者也能从单元测试中受益。在版本 2.1 之前,OCUnit 可以通过手动方式添加到 Xcode 中。但通过将其包含在集成开发环境IDE)中,开始单元测试所需的时间投资大大减少,因此,更多的人开始编写测试。

在 2008 年,OCUnit 被集成到 iPhone SDK 2.2 中,以允许对 iPhone 应用程序进行单元测试。

最后,在 2013 年,随着 XCTest 的引入,单元测试在 Xcode 5 中成为了一等公民。通过 XCTest,苹果在 Xcode 中添加了特定的用户界面元素,这些元素有助于测试,允许运行特定的测试,快速找到失败的测试,并查看所有测试的概览。我们将在本章后面讨论 Xcode 中的测试用户界面。但首先,我们将看看使用 Xcode 进行 TDD 的实际操作。本章为我们开始使用 TDD 构建第一个应用程序奠定了基础。

这些是我们将在本章中涵盖的主要部分:

  • TDD 的一个示例

  • 在 Xcode 中查找有关测试的信息

  • 运行测试

  • 设置和拆除

  • 调试测试

技术要求

本章中所有的代码都已上传(完整形式)在此:

github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter03

TDD 的一个示例

对于这个 TDD 示例,我们将使用我们在第一章你的第一个单元测试中创建的相同项目。在 Xcode 中打开 FirstDemo 项目,通过按⌘**U运行测试。我们编写的用于探索自定义断言功能的测试失败了。我们不再需要这个测试。删除它。

假设我们正在为博客平台构建一个应用程序。当编写新帖子时,用户会为帖子输入一个标题。标题中的所有单词都应该以大写字母开头。

要开始 TDD 工作流程,我们需要一个失败的测试。在编写测试时需要考虑以下问题:

  • 前提条件:在我们调用方法之前,系统的状态是什么?

  • 调用:方法签名应该如何?方法(如果有)的输入参数是什么?

  • 断言:方法调用的预期结果是什么?

对于我们的博客应用示例,以下是一些可能的答案:

  • 前提条件:无。

  • makeHeadline

  • 断言:结果字符串应该相同,但所有单词都应该以大写字母开头。

这就足够我们开始了。进入红色步骤。

标题首字母大写 – 红色

以下步骤将我们带到 TDD 旅程的第一个红色状态:

  1. 打开FirstDemoTests.swift,并将以下代码添加到FirstDemoTests类中:

    // FirstDemoTests.swift
    func 
     test_makeHeadline_shouldCapitalisePassedInString() {
      let blogger = Blogger()
    }
    

这还不是完整的测试方法,因为我们实际上并没有测试任何东西。但我们必须在这个地方停止编写测试,因为编译器抱怨我们没有添加Blogger

按照 TDD 工作流程,我们需要添加代码,直到编译器停止打印错误。记住,在测试中代码无法编译意味着“测试失败。”一个失败的测试意味着我们需要编写代码,直到测试不再失败。

  1. 将 Swift 文件Blogger.swift添加到主目标中,并包含以下代码:

    // Blogger.swift
    import Foundation
    struct Blogger { 
    }
    

Xcode 将测试中的错误替换为警告,指出我们没有使用blogger变量。这是真的。所以让我们使用它。

  1. 更改测试代码,使其看起来像这样:

    // FirstDemoTests.swift
    func
     test_makeHeadline_shouldCapitalisePassedInString() {
      let blogger = Blogger()
      let headline = blogger.makeHeadline(from: "the
        Accessibility inspector")
    }
    

测试仍然没有完成。但又一次,我们不得不停下来,因为编译器在抱怨,这次的信息是类型'Blogger'没有成员'makeHeadline'。所以,即使只有这几行代码,你也能看到测试是如何“驱动”开发的。一步一步地,我们向测试和生产代码中添加代码来实现我们试图构建的功能。

  1. 由于测试目前失败(无法编译),我们需要切换回Blogger结构体并添加一些更多的代码:

    // Blogger.swift
    struct Blogger {
      func makeHeadline(from input: String) -> String {
        return ""
      }
    }
    

再次,这改变了测试代码中的错误,变成了警告,指出我们没有使用标题变量。这是真的。但我们还没有完成测试。

  1. 我们将使用变量next。向测试方法添加以下断言:

    // FirstDemoTests.swift
    func 
     test_makeHeadline_shouldCapitalisePassedInString() {
      let blogger = Blogger()
      let headline = blogger.makeHeadline(from: "the 
        Accessibility inspector")
      XCTAssertEqual(headline, "The Accessibility 
        Inspector")
    }
    

这使得测试可以编译。使用键盘快捷键⌘**U运行测试。

我们刚刚添加的测试编译并失败。我们可以继续到 TDD 工作流程中的绿色阶段。

大写标题 – 绿色

测试失败是因为从makeHeadline(from:)返回的字符串只是一个空字符串。但该方法应该返回输入字符串The Accessibility inspector的大写版本。

按照 TDD 工作流程,我们需要回到实现部分并添加最简单的代码,使测试通过。在Blogger.swift中,修改makeHeadline(from:)的代码,使其看起来像这样:

func makeHeadline(from input: String) -> String {
  return "The Accessibility Inspector"
}

这段代码很愚蠢且错误,但它是最简单的使测试通过的代码。运行测试( U)以确保这是实际情况。

尽管我们刚刚编写的代码对我们尝试实现的功能没有用处,但它对我们这些开发者来说仍然有价值。它告诉我们我们需要另一个测试。

大写标题 – 重构

在编写更多测试之前,我们需要重构现有的测试。在生产代码中,没有什么可以重构的。这段代码既简单又优雅。

但测试可以改进。目前,测试的相关信息有点无结构。这不是大问题,但也许我们可以通过以下步骤提高测试的可读性:

  1. test_makeHeadline_shouldCapitalisePassedInString()测试方法替换为以下代码:

    func 
     test_makeHeadline_shouldCapitalisePassedInString() {
      let blogger = Blogger()
      let input = "the Accessibility inspector"
      let result = blogger.makeHeadline(from: input)
      let expected = "The Accessibility Inspector"
      XCTAssertEqual(result, expected)
    }
    

通过在测试中使用变量,我们使其更容易理解。变量的名称向测试的读者说明了这些值的用途。一个值是输入,一个是结果,另一个是预期值。

运行测试。所有测试都应该仍然通过。但我们如何知道测试是否仍然测试与之前相同的内容?在大多数情况下,我们在重构测试时所做的更改不需要再次测试。但是,有时(例如在这种情况下),确保测试仍然有效是很好的。这意味着我们需要另一个失败的测试。

  1. 前往makeHeadline(from:)并更改返回的字符串如下:

    func makeHeadline(from input: String) -> String {
      return "The Accessibility"
    }
    

我们已经从返回的字符串中移除了最后一个单词。再次运行测试以确保测试现在失败。

  1. 现在将返回的字符串改回The Accessibility Inspector以再次使测试通过。通过运行测试来确认所有测试再次通过。

通过故意使测试失败并在下一步修复它,我们已经证明测试可以失败。这很重要,因为编写总是通过测试的测试很容易发生。例如,如果你忘记添加assert函数,或者由于测试中的某些条件,assert函数从未被触及,测试总是报告为绿色。

注意

总是确认测试可以失败!

我们已经知道实现是不正确的。makeHeadline(from:) 方法总是返回相同的字符串,并忽略了传递给方法中的字符串。但我们所有的测试都通过了。当所有测试都通过,但我们知道我们还没有完成这个功能,这意味着我们需要另一个测试。在 TDD 中,我们总是从一个失败的测试开始。

大写标题 2 – 红色

我们编写的用于使之前的测试通过的生产代码仅适用于一个特定的标题。但我们想要实现的功能必须适用于所有可能的标题。将以下测试添加到 FirstDemoTests.swift 中:

// FirstDemoTests.swift
func test_makeHeadline_shouldCapitalisePassedInString_2() {
  let blogger = Blogger()
  let input = "The contextual action menu"

  let result = blogger.makeHeadline(from: input) 
  let expected = "The Contextual Action Menu"
  XCTAssertEqual(result, expected)
}

运行测试。这个新测试显然失败了。现在,休息一下。去散步或者准备一些饮料。说真的,离开电脑半小时左右。

让我们使测试通过。

大写标题 2 – 绿色

打开 Blogger.swift,将 makeHeadline(from:) 的实现替换为以下代码:

// Blogger.swift
func makeHeadline(from input: String) -> String {
  let words = input.components(separatedBy: " ")
  var headline = ""
  for var word in words {
    let firstCharacter = word.remove(at: word.startIndex)
    headline +=
      "\(String(firstCharacter).uppercased())\(word) "
  }
  headline.remove(at: headline.index(before:
    headline.endIndex))
  return headline
}

对于这个实现感到震惊是可以的。通过这段代码,我试图说明,在 TDD 的绿色步骤中,只要代码能让测试通过,任何代码都是好的。我们将在下一节中改进这段代码。

让我们一步一步地通过这个实现:

  1. 将字符串拆分为单词。

  2. 遍历单词,删除第一个字符并将其转换为大写。将更改后的字符添加到单词的开头。将带有尾随空格的此单词添加到标题字符串中。

  3. 删除最后一个空格并返回字符串。

运行测试。所有测试都通过了。在 TDD 的工作流程中,接下来要执行的是重构。

小贴士

不要跳过重构。这一步与红色和绿色步骤一样重要。直到没有可以重构的内容,你才算完成。

大写标题 2 – 重构

让我们从测试开始重构:

  1. 两个 makeHeadline 测试都以创建 Blogger 的实例开始。这是代码重复,是重构的好候选。

FirstDemoTests 类的开头添加以下属性:

// FirstDemoTests.swift
var blogger: Blogger!
  1. 记住,setUp() 方法在每次测试执行之前被调用。因此,它是初始化 blogger 属性的完美位置:

    // FirstDemoTests.swift
    override func setUpWithError() throws {
      blogger = Blogger()
    }
    
  2. 每个测试运行后都应该清理。因此,将以下代码添加到 tearDownWithError() 中:

    // FirstDemoTests.swift
    override func tearDownWithError() throws {
      blogger = nil
    }
    
  3. 现在我们可以从标题测试中删除 let blogger = Blogger() 行。运行测试以确保它们仍然可以编译和运行。

  4. 现在我们需要重构实现代码。我们目前拥有的实现看起来像是从 Objective-C 转换到 Swift(如果你还没有使用过 Objective-C,你必须相信我)。但是 Swift 是不同的,并且有许多概念使得可以编写更少的代码,这些代码更容易阅读。让我们使实现更加敏捷。将 makeHeadline(from:) 替换为以下代码:

    // Blogger.swift
    func makeHeadline(from input: String) -> String {
      return input.capitalized
    }
    

这有多酷?Swift 甚至在 String 类中提供了一个方法来做这件事。再次运行测试以确保我们没有在重构中破坏任何东西。所有测试都应该仍然通过。

回顾

在本节中,我们使用 TDD 工作流程向我们的项目添加了一个功能。我们从失败的测试开始。我们使测试通过。最后,我们对代码进行了重构以使其更简洁。您在这里看到的步骤似乎非常简单且微不足道,以至于您可能认为可以跳过一些测试而仍然做得很好。但那样就不再是 TDD 了。TDD 的美妙之处在于步骤非常简单,以至于您不必去思考。您只需记住下一步是什么。

因为步骤和规则如此简单,您不必浪费脑力去思考这些步骤的实际含义。您唯一需要记住的是红色、绿色和重构。因此,您可以专注于困难的部分:编写测试使测试通过改进代码

现在我们知道了如何编写测试,让我们看看在 Xcode 中我们可以在哪里找到有关我们的测试的信息。

在 Xcode 中查找测试信息

在 Xcode 5 和 XCTest 的引入之后,单元测试被紧密集成到 Xcode 中。Apple 添加了许多 UI 元素来导航到测试、运行特定测试和查找有关失败的测试的信息。多年来,他们进一步改进了集成。这里的一个关键元素是测试导航器

测试导航器

要打开测试导航器,点击导航面板中的带减号的菱形按钮或使用快捷键 6

图 3.1 – Xcode 中的测试导航器

图 3.1 – Xcode 中的测试导航器

测试导航器显示了打开的项目或工作区中的所有测试。在上面的屏幕截图中,您可以看到我们的演示项目的测试导航器。在该项目中,有两个测试目标,一个用于单元测试,一个用于 UI 测试。对于复杂的应用程序,拥有多个单元测试目标可能很有用,但这超出了本书的范围。测试的数量显示在测试目标的名称后面。在我们的例子中,单元测试目标中有三个测试。

在导航器的底部有一个过滤器控件,您可以使用它来过滤显示的测试。一旦您开始输入,显示的测试就会使用模糊匹配进行过滤。控件中有一个显示带交叉的菱形的按钮:

图 3.2 – 测试导航器中仅显示失败测试的按钮

图 3.2 – 测试导航器中仅显示失败测试的按钮

如果选择此按钮,列表中仅显示失败的测试。通过右侧的按钮,您可以过滤所有跳过的测试。

测试概览

Xcode 还有一个测试概览,其中所有测试的结果都收集在一个地方。要打开它,在导航面板中选择报告导航器,然后选择列表中的最后一个测试:

图 3.3 – 在报告导航器中访问测试概览

图 3.3 – 在报告导航器中访问测试概览

如果你想要比较不同的测试运行,你还可以在列表中选择其他测试。在右侧的编辑器中,显示了所选测试运行中所有测试的概览:

图 3.4 – 上次测试运行中的测试概览

图 3.4 – 上次测试运行中的测试概览

当你用鼠标指针悬停在某个测试上时,会出现一个带有向右箭头的圆圈。如果你点击箭头,Xcode 将在编辑器中打开测试。

在报告导航器中,还有一个日志项。它以树状结构显示所有测试。以下是一个示例:

图 3.5 – 测试报告日志

图 3.5 – 测试报告日志

日志显示了测试用例(在这个例子中,一个测试用例)以及测试用例内的测试(在这个例子中,两个失败的和一个通过的测试)。此外,你还可以看到每个测试用例甚至每个测试需要执行的时间。

在 TDD 中,测试执行得快是很重要的。你希望能够在不到一秒的时间内执行整个测试套件。否则,整个工作流程将受测试执行的影响,测试可能会分散你的注意力和专注力。你不应该被诱惑切换到另一个应用程序(如 Safari),因为测试可能需要半分钟。

如果你注意到测试套件运行时间过长,不切实际,请打开日志并搜索那些减慢测试速度的测试,并尝试使测试更快。

现在我们已经看到了在哪里可以找到关于我们的测试的信息,在下一节中,我们将探讨运行测试的不同方法。

运行测试

Xcode 提供了许多不同的执行测试的方法。你已经看到了执行测试套件中所有测试的两种方法:转到项目 | 测试菜单项或使用 ⌘**U 键盘快捷键。

运行一个特定的测试

在 TDD 中,你通常希望尽可能频繁地运行所有测试。运行测试可以让你有信心,代码在编写测试时确实做了你想要的事情。此外,你希望在新的代码破坏看似无关的功能时立即获得反馈(即,失败的测试)。立即反馈意味着你对破坏功能的更改的记忆是新鲜的,修复也是迅速进行的。

然而,有时你可能需要运行一个特定的测试,但不要让它成为一种习惯。要运行一个特定的测试,你可以点击在测试方法旁边可见的菱形:

图 3.6 – 通过点击侧边栏中测试方法的菱形来运行一个特定的测试

图 3.6 – 通过点击侧边栏中测试方法的菱形来运行一个特定的测试

当您点击它时,生产代码将在模拟器或设备上编译并启动,并执行测试。

另一种执行特定测试的方法是。当您打开测试导航器并将鼠标悬停在某个测试上时,测试方法名旁边会显示一个带有播放图标的圆形:

![Figure 3.7 – 点击测试导航器中测试旁边的菱形以运行此测试]

![Figure 3.07_B18127.jpg]

![Figure 3.7 – 点击测试导航器中测试旁边的菱形以运行此测试]

再次,如果您点击此测试,它将单独运行。

测试框架通过方法名的前缀来识别测试。如果您想运行所有测试但排除一个,请从该测试方法名的开头移除 test 前缀。

在测试用例中运行所有测试

与运行特定测试相同,您也可以运行特定测试用例的所有测试。点击测试用例定义旁边的菱形,或者当您在测试导航器中将鼠标悬停在测试用例名称上时出现的播放按钮。

运行一组测试

您可以通过编辑构建方案来选择运行一组测试。要编辑构建方案,请点击 Xcode 工具栏中的方案,然后点击 编辑方案...

![Figure 3.08 – 打开方案编辑器]

![Figure 3.08_B18127.jpg]

图 3.8 – 打开方案编辑器

然后,选择 测试,通过点击小三角形来展开测试套件。在右侧,有一个名为 测试 的列:

![Figure 3.09 – 方案编辑器中的测试设置]

![Figure 3.09_B18127.jpg]

![Figure 3.09 – 方案编辑器中的测试设置]

选择的方案仅运行已勾选的测试。默认情况下,所有测试都是勾选的,但您可以根据需要取消勾选某些测试。但别忘了在完成时再次勾选所有测试。

作为替代方案,您可以添加一个构建方案,用于运行一组您想要定期运行而无需运行所有测试的测试。

但如前所述,您应该尽可能经常运行完整的测试套件。

下一个部分将展示如何在每次测试调用前后添加代码。

设置和清理

我们在本章前面已经看到了 setUpWithError()tearDownWithError() 实例方法。setUpWithError() 实例方法中的代码在每个测试调用之前运行。在我们的示例中,我们使用 setUpWithError() 来初始化我们想要测试的 Blogger。由于它在每个测试调用之前运行,每个测试都使用其自己的 Blogger 实例。我们对这个特定实例所做的更改不会影响其他测试。测试是相互独立执行的。

tearDownWithError() 实例方法在每个测试调用之后运行。使用 tearDownWithError() 来执行必要的清理工作。在示例中,我们在 tearDownWithError() 方法中将 blogger 设置为 nil

除了实例方法之外,还有setUp()tearDown()类方法。这些方法分别在测试用例的所有测试之前和之后运行。

调试测试

有时,但并不经常,你可能需要调试你的测试。与正常代码一样,你可以在测试代码中设置断点。然后,调试器会在该断点处停止代码执行。你还可以在将被测试的代码中设置断点,以检查你是否遗漏了某些内容,或者你想要测试的代码是否实际上被执行了。

为了了解这是如何工作的,让我们在先前的示例中向测试添加一个错误并对其进行调试:

  1. 打开 FirstDemoTests.swift 并将测试方法 test_makeHeadline_shouldCapitalisePassedInString_2() 替换为以下代码:

    // FirstDemoTests.swift
    func 
     test_makeHeadline_shouldCapitalisePassedInString_2()
     { 
      let input = "The contextual action menu"  
      let result = blogger.makeHeadline(from: input)
      let expected = "The ContextuaI Action Menu"
      XCTAssertEqual(result, expected)
    }
    

你看到我们引入的错误了吗?期望字符串的值中有一个拼写错误。Contextual中的最后一个字符是大写“i”,而不是小写“l”。运行测试。测试失败,Xcode 会告诉你问题所在。

  1. 但为了这个练习,让我们在XCTAssertEqual()函数所在的行设置一个断点。单击你想要设置断点的行的左侧区域。你必须单击红色菱形旁边的区域。结果,你的编辑器将看起来类似于以下:

图 3.10 – 在断言所在的行添加断点

图 3.10 – 在断言所在的行添加断点

  1. 再次运行测试。测试执行在断点处停止。如果调试控制台尚未打开,请打开它(转到(lldb)并出现闪烁的光标。输入po expected并按Enterpo是“打印对象”命令。正如其名所示,它会打印对象的表示:

    (lldb) po expected
    "The ContextuaI Action Menu"
    
  2. 现在打印结果值:

    (lldb) po result
    "The Contextual Action Menu"
    

因此,借助调试器,你可以找出发生了什么。

正如我们所见,当我们运行测试时,调试器会附加到正在运行的应用程序上。这意味着当运行测试时,生产代码中的断点也会被触发。

小贴士

要了解更多关于调试器的信息,请在 Apple 文档中搜索lldb

目前,请将expected字符串常量中的拼写错误保留原样,但通过用鼠标将其从编辑器左侧的区域拖动来删除断点。

在测试失败时中断的断点

Xcode 内置了一个断点,当测试失败时触发。当设置此断点时,测试执行将停止,并且每当测试失败时,都会启动一个调试会话。

通常,这并不是你想要的,因为在 TDD 中,失败的测试是正常的,你不需要调试器来找出发生了什么。你明确地在 TDD 工作流程周期的开始处编写了测试以失败。

但如果你需要调试一个或多个失败的测试,了解如何激活此断点是有好处的。打开断点导航器:

图 3.11 – 断点导航器

图 3.11 – 断点导航器

在导航视图的底部有一个带有加号(+)的按钮。点击它,然后选择测试失败断点

图 3.12 – 选择测试失败断点

图 3.12 – 选择测试失败断点

正如其名所示,此断点在测试失败时停止测试的执行。在我们的例子中,我们仍然有一个失败的测试。运行测试以查看断点的作用。

调试器在断言所在的行停止,因为测试失败。就像前面的例子一样,你可以得到一个调试会话,以便你可以输入 LLDB 命令来找出测试失败的原因。

再次移除断点,因为在执行 TDD 时它不太实用。在 TDD 中,我们总是有失败的测试。测试失败断点会过多地干扰 TDD 流程。

测试再次功能

现在,让我们修复测试中的错误,并学习如何再次运行前面的测试。打开 FirstDemoTests.swift 并通过点击测试方法旁边的菱形符号来仅运行失败的测试。测试仍然失败。通过将 ContextuaI 中的最后一个字符在 expected 字符串常量中更改为 "l" 来修复它。然后,转到产品 | 执行操作 | 再次测试 "test_makeHeadline_shouldCapitalisePassedInString_2()",或使用快捷键 ⌃⌥⌘**G 再次运行前面的测试。快捷键在处理特定功能时特别有用,当你需要测试实现是否足够时。

摘要

在本章中,我们探讨了在 Xcode 中单元测试和 TDD 的工作方式。我们看到了真实的测试在测试真实的代码。通过使用 Xcode 的不同测试相关功能,我们学习了如何编写、运行和修复测试,以及如何找到与测试相关的信息。所有这些对于本书的其余部分都很重要。我们需要知道如何在执行 TDD 时使用 Xcode。

在下一章中,我们将通过测试驱动开发来确定我们将要构建的应用程序。

练习

  1. 编写一个测试方法,该方法反转一个字符串。编写使测试通过的代码。

  2. 编写一个测试方法,该方法接受一个标题并从中创建一个文件名。在文件名中,确保空格被替换为 _,并且它只包含小写字母。

第二部分 – 数据模型

在大多数项目中,模型层是测试起来最容易的部分。既然我们刚开始,为模型对象编写测试有助于我们进入测试模式的心态。

在本节中,我们将涵盖以下章节:

  • 第四章, 我们将要构建的应用

  • 第五章, 构建待办事项的结构

  • 第六章, 测试、加载数据和保存数据

第四章:第四章:我们将要构建的应用

在前面的章节中,你学习了如何编写单元测试,并看到了测试驱动开发TDD)的一个简单示例。当开始 TDD 时,对大多数人来说编写单元测试很容易。困难的部分是将知识从编写测试转移到驱动开发。可以假设什么?在我们编写第一个测试之前应该做什么?我们应该测试什么才能最终得到一个完整的应用?

作为开发者,你习惯于从代码的角度思考。当你看到应用需求列表上的一个功能时,你的大脑已经开始为这个功能规划代码。对于 iOS 开发中反复出现的问题(如构建表格视图),你很可能已经开发了自己的最佳实践。

在 TDD 中,你在编写测试时不应考虑代码。测试必须描述被测试单元应该做什么,而不是它应该如何做。应该能够在不破坏测试的情况下更改实现。这种思维方式是 TDD 的难点。你需要练习才能使它变得自然。

为了练习这种开发方法,我们将在这本书的剩余部分开发一个简单的待办事项列表应用。故意让它变得无聊且简单。我们想专注于 TDD 工作流程,而不是复杂的实现。一个有趣的应用会分散本书的重点——如何执行 TDD。

本章介绍了我们将要构建的应用,并展示了完成应用将拥有的视图。

这是本章的主要主题:

  • 待办事项列表

  • 待办事项详情视图

  • 添加待办事项的视图

  • 应用的结构

  • 在 Xcode 中开始

技术要求

本章中的所有代码都已上传(完整形式)在此:

github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter04

待办事项列表

当启动应用(我们将要构建的应用)时,用户会在他们的 iOS 设备屏幕上看到待办事项列表。列表中的项目包括标题、可选的位置和截止日期。可以通过使用添加(+)按钮将新项目添加到列表中,该按钮显示在视图的导航栏中。任务列表视图将看起来像这样:

![图 4.1 – 待办事项列表图 4.01 – 待办事项列表

图 4.1 – 待办事项列表

作为用户,我有以下要求:

  • 我想在打开应用时看到待办事项列表。

  • 我想将待办事项添加到列表中。

在待办事项列表应用中,用户显然需要在完成事项时能够勾选它们。勾选的事项会显示在未勾选事项下方,并且可以再次取消勾选。应用使用表格视图的用户界面中的删除按钮来勾选和取消勾选事项。勾选的事项将被放在列表末尾的完成标题部分。待办事项列表的用户界面看起来如下:

图 4.2 – 可以勾选待办事项为完成

图 4.2 – 可以勾选待办事项为完成

作为用户,我有以下要求:

  • 我想要勾选一个待办事项以标记它为完成。

  • 我想要看到所有勾选的事项都跟在未勾选事项之后。

  • 我想要取消勾选一个待办事项。

  • 我想要删除所有待办事项。

当用户点击一个条目时,该条目的详细信息将在任务详情视图中显示。

待办事项详情视图

任务详情视图显示了存储在待办事项中的所有信息。信息包括标题、截止日期、位置(名称和地址)以及描述。如果提供了地址,则会显示带有地址的地图。详情视图还允许勾选事项为完成。详情视图看起来如下:

图 4.3 – 待办事项详情视图

图 4.3 – 待办事项详情视图

作为用户,我有以下要求:

  • 我在列表中点击了一个待办事项,并想要看到它的详情。

  • 我想要从详情视图中勾选待办事项。

您需要能够将待办事项添加到列表中。下一节将展示这个输入视图将看起来是什么样子。

添加待办事项的视图

当用户在列表视图中选择添加(+)按钮时,将显示任务输入视图。用户可以为任务添加信息。标题是必需的。只有提供了标题时,才能选择保存按钮。无法添加列表中已经存在的任务。取消按钮将关闭视图。任务输入视图将看起来如下:

图 4.4 – 添加待办事项到列表的视图

图 4.4 – 添加待办事项到列表的视图

作为用户,我有以下要求:

  • 假设我在项目列表中点击了添加(+)按钮,我想要看到一个表单来输入待办事项的详情(标题、可选日期、可选位置名称、可选地址和可选描述)。

  • 我想要通过点击保存按钮将待办事项添加到待办事项列表中。

我们将不会实现任务的编辑和删除功能,但当你完全阅读完这本书后,通过先编写测试,你将很容易自己添加这个功能。

请记住,我们不会测试应用的外观和设计。单元测试无法确定应用是否看起来符合预期。单元测试可以测试功能,而这些功能与其展示是独立的。原则上,我们可以为 UI 元素的定位和颜色编写单元测试,但这些事情在开发早期阶段很可能会有很大变化。我们不希望仅仅因为按钮移动了 10 个点就出现失败的测试。

然而,我们将测试视图中的 UI 元素是否存在。如果你的用户看不到任务的信息,或者无法添加任务的所有信息,那么该应用不符合要求。

在下一节中,我们将讨论我们将要构建的应用的结构。

应用结构

在我们开始实现待办应用的不同视图之前,我们需要考虑应用的结构。故意使应用保持简单,以帮助集中关注本书的主要主题:使用 TDD 构建应用。

表格视图控制器、代理和数据源

在 iOS 应用中,数据通常使用表格视图来展示。表格视图在性能上高度优化;它们易于使用和实现。我们将使用表格视图来展示待办事项列表。

表格视图通常由UITableViewController表示,它也是表格视图的数据源和代理。这通常会导致一个庞大的表格视图控制器,因为它做了太多事情:展示视图、导航到其他视图控制器,以及管理表格视图中数据的展示。

为了减轻表格视图控制器的责任,我们将使用协调器模式。这样,协调器负责在应用的不同视图之间导航。由于我们的应用相当简单,我们只需要一个协调器来管理整个应用。

表格视图控制器与协调器类之间的通信将通过协议来定义。协议定义了一个类的接口看起来是什么样子。这有一个很大的好处:如果我们需要用更好的版本替换实现(可能是因为你学会了以更好的方式实现功能),我们只需要针对清晰的应用程序编程接口API)进行开发。其他类的内部工作方式并不重要。

表格视图单元格

如前述截图所示,待办事项列表项有一个标题,并且可选地可以有一个截止日期和位置名称。表格视图单元格应仅显示设置的数据。我们将通过实现我们自己的自定义表格视图单元格来完成这一点。

模型

应用程序的模型由待办事项、位置和项目管理者组成,它允许添加和删除项目,并负责管理项目。因此,控制器将向项目管理者请求要显示的项目。项目管理者还将负责在磁盘上存储项目。

初学者往往倾向于在控制器内管理模型对象。然后,控制器有一个对项目集合的引用,项目的添加和删除直接由控制器完成。这不被推荐,因为如果我们决定更改项目的存储(例如,使用 Core Data),它们的添加和删除必须在控制器内进行更改。这样的类很难保持概览,因为它做了许多不同且不相关的事情;因此,它可能是一个错误源。

在控制器和模型对象之间有一个清晰的接口要容易得多,因为如果我们需要更改模型对象的管理方式,控制器可以保持不变。如果我们只是保持接口不变,甚至可以替换整个模型层。本书的后面,我们将看到这种解耦也有助于使测试更容易。

其他视图

应用程序将有两个额外的视图:任务详情视图和任务输入视图。

当用户在列表中点击待办事项时,该事项的详细信息将在任务详情视图控制器中显示。从详情屏幕,用户将能够勾选一个事项。

新的待办事项将通过输入视图添加到列表中。此视图将使用 SwiftUI 实现。

开发策略

在本书中,我们将从内到外构建应用程序。我们将从模型开始,然后构建控制器和网络。本书的结尾,我们将把所有东西组合在一起。

通常,在进行 TDD 时,你可能会按功能一个接一个地构建应用程序,但通过按层而不是按功能分离,更容易跟踪和了解正在发生的事情。当你后来需要刷新记忆时,你需要的相关信息更容易找到。

在下一节中,我们将设置 Xcode 中的应用程序并调整一些 Xcode 的行为。

在 Xcode 中开始

现在,让我们通过创建一个项目开始我们的旅程,我们将使用 TDD 来实现它。按照以下步骤进行:

  1. 打开 Xcode 并使用应用程序模板创建一个新的 iOS 项目。

  2. 在“ToDo”作为产品名称时,选择“Storyboard”界面和“Swift”作为语言,并勾选包含测试旁边的框。让使用 Core Data框保持未勾选。

Xcode 创建了一个包含三个目标的 iOS 项目:一个用于实现代码,一个用于单元测试,一个用于 UI 测试。模板包含在屏幕上显示单个视图的代码。

  1. 要查看应用目标与测试目标如何配合,请在项目导航器中选择项目,然后选择 ToDoTests 目标。在 General 选项卡中,你会找到一个设置,用于指定测试目标应该能够测试的 Host Application。它看起来像这样:

图 4.5 – 测试目标的常规设置

图 4.5 – 测试目标的常规设置

Xcode 已经正确设置了测试目标,以便我们可以测试将要写入应用目标中的实现。

  1. 不幸的是,Xcode 还为 UI 测试创建了一个测试目标。UI 测试对于 TDD 来说太慢了。为了保持测试运行时的快速反馈,我们需要禁用主方案中的 UI 测试。点击 Xcode 窗口顶部的 Build 信息栏中的方案,并选择 Test 阶段。接下来,取消勾选 UI 测试目标旁边的复选框。这个过程在下面的屏幕截图中有说明:

图 4.6 – 禁用 UI 测试

图 4.6 – 禁用 UI 测试

设置有用的 Xcode 测试行为

Xcode 有一个名为 Behaviors 的功能。通过使用行为和标签,Xcode 可以根据其状态显示有用的信息。

通过导航到 Xcode | Behaviors | Edit Behaviors 打开 Behaviors 窗口。在左侧可以看到你可以添加行为的不同阶段(BuildTestingRunning 等等)。这些行为在进行 TDD 时非常有用。

这里显示的行为是我认为有用的。尝试调整设置,找到对你最有用的那些。总的来说,我推荐使用行为,因为我认为它们可以加快开发速度。

有用的构建行为

当构建开始时,Xcode 编译文件并将它们链接在一起。要查看正在发生的事情,你可以在构建开始时激活 Build 日志。建议你在新标签页中打开 Build 日志,因为这样可以在构建过程中没有错误发生时切换回代码编辑器:

  1. 选择 Starts 阶段,并勾选 window tabShow 选项。

  2. 将名称 Log 输入到 named 字段或使用一个表情符号。

  3. 选择 navigatorIssuesShow 选项。

  4. 在窗口底部,勾选 Navigate to 并选择 current log。在做出这些更改后,设置窗口将看起来像这样:

图 4.7 – 在构建开始时显示构建日志的行为

图 4.7 – 在构建开始时显示构建日志的行为

  1. 构建并运行以查看行为的外观。

测试行为

我有一个用于编码的窗口标签。这个标签的名称是 🤓。通常,在这个标签中,测试在左侧打开,而在 Assistant Editor 中是待测试的代码(或者在 TDD 的情况下,是待编写的代码)。它看起来像这样:

图 4.8 –  选项卡

图 4.8 – 🤓 选项卡

当测试开始时,我们希望再次看到代码编辑器。因此,我们添加了一个显示🤓标签的行为。除此之外,我们还想看到带有控制台视图的测试导航器和调试器。

当测试成功时,Xcode 应该显示一个边框来通知我们所有测试都已通过。导航到测试 | 成功阶段,并检查使用系统通知通知设置。除此之外,它还应该隐藏导航器和调试器,因为我们想专注于重构或编写下一个测试。

如果测试失败(在 TDD 中这种情况经常发生),Xcode 应该再次显示通知。我喜欢隐藏调试器,因为通常情况下,它不是找出失败测试中发生什么情况的最佳位置。在大多数 TDD 的情况下,我们 already know what the problem is.

你甚至可以让你的 Mac 朗读公告。检查使用朗读公告并选择你喜欢的声音,但要注意不要惹恼你的同事。你将来可能需要他们的帮助。

现在,项目和 Xcode 已经设置好了,我们可以开始我们的 TDD 之旅了。

摘要

在本章中,我们查看了一下我们将在本书的整个过程中构建的应用程序。我们查看了一下完成应用后屏幕将如何显示。我们创建了一个稍后将要使用的项目,并了解了 Xcode 的行为。

在下一章中,我们将使用 TDD 开发应用程序的数据模型。我们将在可能的地方使用结构体来表示模型,因为模型在 Swift 中最好用值类型来表示。

练习

  1. 使用 Xcode 中的故事板复制模拟屏幕。

  2. 修改行为,以便你可以在不查看屏幕的情况下确定测试是否失败或所有测试是否通过。

第五章:第五章:构建待办事项的结构

iOS 应用通常使用一种称为 模型-视图-控制器MVC)的设计模式进行开发。在这个模式中,每个类、结构体或枚举要么是模型对象、视图,要么是控制器。模型对象负责存储数据。它们应该独立于 UI 提供的展示方式。例如,应该能够使用相同的模型对象在 iOS 应用和 macOS 的命令行工具中使用。

视图对象呈现数据。它们负责使对象对用户可见(或在启用了语音覆盖的应用中可听),对于应用运行在的设备而言,视图是特殊的。在跨平台应用中,视图对象不能共享。每个平台都需要实现视图层。

控制器对象在模型和视图对象之间进行通信。它们负责使模型对象可展示。

我们将使用 MVC 为我们的待办事项应用设计,因为它是最容易的设计模式之一,并且苹果在它的示例代码中广泛使用。

本章从我们的应用模型层开始,引领我们进入 TDD 领域。到本章结束时,我们将有一个可以存储待办事项所有信息的结构,包括可选的位置。

在本章中,我们将涵盖以下主题:

  • 实现 ToDoItem 结构体

  • 实现 Location 结构体

技术要求

本章的所有代码都可以在这里找到(完整形式):github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter05

实现 ToDoItem 结构体

为了有用,待办事项需要一组最小信息。在本节中,我们将创建一个结构体来保存这些信息,同时使用测试来指导其开发。

一个待办事项应用需要一个模型类/结构体来存储待办事项的信息:

  1. 我们将首先向单元测试目标添加一个新的测试用例。打开我们在 第四章开始使用 Xcode 部分创建的待办事项项目,选择 ToDoTests 组。

  2. 前往 ToDoItemTests,将其设置为 XCTestCase 的子类,选择 Swift 作为语言,然后点击 下一步

  3. 在下一个窗口中,点击 创建

  4. 现在,删除 ToDoTests.swift 模板测试用例。

添加标题属性

待办事项需要一个 title。按照以下步骤向我们的 ToDoItem 结构体添加一个:

  1. 打开 ToDoItemTests.swift 文件,并在 import XCTest 语句下方添加以下导入表达式:

    @testable import ToDo
    

这是为了能够测试 ToDo 模块。@testable 关键字使 ToDo 模块的内部方法对测试用例可访问。或者,您可以使用 publicopen 访问级别从测试目标中使方法可访问。但您应该只在需要这些访问级别时这样做,因为例如,该方法是一个 Swift 包的一部分。

  1. 删除两个模板测试方法,testExample()testPerformanceExample()

  2. 待办事项的 title 字符串是必需的。让我们编写一个测试来确保存在一个初始化器,它将接受一个 title 字符串。将以下测试方法添加到测试用例的末尾(但仍在 ToDoItemTests 类中):

    // ToDoItemTests.swift
    func test_init_takesTitle() {
      ToDoItem(title: "Dummy")
    }
    
  3. 集成到 Xcode 中的静态分析器会抱怨它 在作用域中找不到 'ToDoItem'

图 5.1 – Xcode 告诉我们找不到 ToDoItem 类型

图 5.1 – Xcode 告诉我们找不到 ToDoItem 类型

我们无法编译此代码,因为 Xcode 找不到 ToDoItem 类型。一个无法编译的测试是一个失败的测试;一旦我们有一个失败的测试,我们就需要编写实现代码来使测试通过。

  1. 要添加实现代码的文件,首先,在 项目 导航器中点击 ToDo 组。否则,添加的文件将被放入测试组。

  2. 打开 ToDoItem.swift 文件,确保文件已添加到 ToDo 目标,而不是 ToDoTests 目标,并点击 创建

![图 5.2 – 将文件添加到主目标]

](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/tstdvn-ios-dev-swift-4e/img/Figure_5.02_B18127.jpg)

图 5.2 – 将文件添加到主目标

  1. 在编辑器中打开 ToDoItem.swift 并添加以下代码:

    // ToDoItem.swift
    struct ToDoItem {
    }
    

这段代码是一个名为 ToDoItem 的结构的完整实现。因此,Xcode 现在应该能够找到 ToDoItem 标识符。

  1. 通过访问 ToDoItem 来运行测试,如下所示:

    let item = ToDoItem()
    
  2. 然而,我们希望有一个接受标题的初始化器。我们需要添加一个名为 titleString 类型的属性来存储标题字符串:

    // ToDoItem.swift
    struct ToDoItem {
      let title: String
    }
    

再次运行测试;它将通过。我们已经使用 TDD 实现了我们待办事项应用的第一微功能——而且这并不困难。在这本书的其余部分,我们将反复这样做,直到应用完成。但首先,我们需要检查现有的测试和实现代码中是否有任何需要重构的内容。测试和代码都是干净且简单的,所以目前没有需要重构的内容。

小贴士

总是记得在测试变绿后检查是否需要重构。

然而,关于测试还有一些需要注意的事项。首先,Xcode 显示一个警告,指出 _ = ToDoItem(title: "Foo")。这告诉 Xcode 我们知道我们在做什么。我们想调用 ToDoItem 的初始化器,但我们不关心它的返回值。

其次,测试中没有 XCTAssert 函数调用。要添加断言,我们可以将测试重写如下:

func test_init_takesTitle() {
  let item = ToDoItem(title: "Dummy")
  XCTAssertNotNil(item, "item should not be nil")
}

但在 Swift 中,一个非失败的初始化器不能返回 nil。它总是返回一个有效的实例。这意味着 XCTAssertNotNil() 方法是没有用的。我们不需要它来确保我们已经编写了足够的代码来实现测试的微特性。它不是驱动开发所必需的,而且它不会使代码变得更好。

在我们进行接下来的几个测试之前,让我们以使 TDD 工作流程更简单、更快捷的方式设置编辑器。首先,在编辑器中打开 ToDoItemTests.swift。然后,打开 ToDoItem.swift 以在辅助编辑器中打开它。根据你的屏幕大小和偏好,你可能更喜欢再次隐藏导航器。使用这种设置,你可以将测试和代码并排放置,从测试切换到代码以及反之,都无需花费任何时间。此外,由于在编写代码时相关测试是可见的,它可以指导实现。

添加 itemDescription 属性

一个待办事项可以有一个描述。我们希望有一个初始化器,它也接受一个描述字符串。让我们开始:

  1. 为了驱动实现,我们需要一个失败的测试来验证这个初始化器的存在:

    // ToDoItemTests.swift
    func test_init_takesTitleAndDescription() {
      _ = ToDoItem(title: "Dummy",
                   itemDescription: "Dummy Description")
    }
    

再次,这段代码无法编译,因为在调用中有一个额外的名为 itemDescription 的参数。

  1. 为了使这个测试通过,我们必须向 ToDoItem 添加一个 String? 类型的 itemDescription 属性:

    // ToDoItem.swift
    struct ToDoItem {
      let title: String
      let itemDescription: String?
    }
    
  2. 运行测试。test_init_takesTitle() 测试将会失败(也就是说,它将无法编译),因为有一个 init 方法也可以为参数设置默认值。你将使用这个特性,如果初始化器中没有为 itemDescription 设置参数,则将其设置为 nil

  3. 将以下代码添加到 ToDoItem 中:

    // ToDoItem.swift
    init(title: String,
         itemDescription: String? = nil) {
      self.title = title
      self.itemDescription = itemDescription
    }
    

这个初始化器有两个参数。第二个参数有一个默认值,所以我们不需要提供两个参数。当省略第二个参数时,将使用默认值。

  1. 现在,运行测试以确保两个测试都通过。

移除隐藏的错误来源

为了能够只通过设置标题来使用简短的初始化器,我们需要自己定义它。但这同时也引入了一个新的潜在错误来源。我们可以移除我们已实现的两个微特性,并且两个测试仍然可以通过。要查看这是如何工作的,请打开 ToDoItem.swift 并注释掉初始化器中的属性和赋值:

struct ToDoItem {
//  let title: String
//  let itemDescription: String?

  init(title: String,
       itemDescription: String? = nil) {

//    self.title = title
//    self.itemDescription = itemDescription
  }
}

运行测试。两个测试仍然会通过。原因在于它们并没有检查初始化器参数的值是否被设置到了任何 ToDoItem 属性上。我们可以轻松地扩展测试以确保这些值被设置。首先,让我们将第一个测试的名称改为 test_init_whenGivenTitle_setsTitle() 并替换为以下代码:

// ToDoItemTests.swift
func test_init_whenGivenTitle_setsTitle() {
  let item = ToDoItem(title: "Dummy")
  XCTAssertEqual(item.title, "Dummy")
}

这个测试无法编译,因为 ToDoItem 没有标题属性(它是被注释掉的)。这表明测试现在正在测试我们的意图。移除标题属性的注释符号和在初始化器中标题的赋值,然后再次运行测试。所有的测试都会通过。现在,用这个测试替换第二个测试:

// ToDoItemTests.swift
func test_init_whenGivenDescription_setsDescription() {
  let item = ToDoItem(title: "Dummy",
                      itemDescription: "Dummy Description")
  XCTAssertEqual(item.itemDescription, "Dummy Description")
}

移除 ToDoItem 中的剩余注释符号,再次运行测试。这两个测试都会再次通过,并且现在它们测试初始化器是否工作。

小贴士

使用可读的测试方法名是一个好主意——也就是说,能够讲述测试故事的名称。使用如 test_<方法名>_<前提条件>_<预期行为> 这样的模式是很常见的。这样,当测试失败时,方法名就告诉你所有关于测试你需要知道的信息。在这本书中,我们将尝试遵循这个模式,但由于空间有限,我们将在代码难以阅读时省略一些信息(例如,前提条件)。你应该开发一个模式并在所有测试中使用它。

添加时间戳属性

一个待办事项也可以有一个由 timestamp 属性表示的截止日期:

  1. 添加以下测试以确保我们可以使用 timestamp 初始化 ToDoItem 的实例:

    // ToDoItemTests.swift
    func test_init_setsTimestamp() {
      let dummyTimestamp: TimeInterval = 42.0
      let item = ToDoItem(title: "Dummy",
                          timestamp: dummyTimestamp)
      XCTAssertEqual(item.timestamp, dummyTimestamp)
    }
    

再次,这个测试无法编译,因为初始化器中有一个额外的参数。从其他属性的实现中,我们知道我们必须在 ToDoItem 中添加一个 timestamp 属性并在初始化器中设置它。

  1. ToDoItem 改成如下所示:

    // ToDoItem.swift
    struct ToDoItem {
      let title: String
      let itemDescription: String?
      let timestamp: TimeInterval?
    
      init(title: String,
           itemDescription: String? = nil,
           timestamp: TimeInterval? = nil) {
    
        self.title = title
        self.itemDescription = itemDescription
        self.timestamp = timestamp
      }
    }
    
  2. 运行测试。如果有点运气,所有的测试都会通过。但如果它们在你的电脑上没有通过,会发生什么呢?这种情况的原因可能是因为我们使用 XCTAssertEqual(_:_:) 来比较两个 TimeInterval 结构。TimeIntervalDouble 类型的一个别名。双精度浮点数是浮点数,因此很难相互比较。通常,你无法判断两个浮点数是否相等。你只能判断它们在某种精度下是否相等。这就是为什么 XCTest 提供了一个具有精度的断言方法。

  3. test_init_setsTimestamp() 中的断言方法调用替换为以下方法调用:

    XCTAssertEqual(item.timestamp!,
                   dummyTimestamp,
                   accuracy: 0.000_001)
    

运行测试。你会看到所有的测试都通过了。

你可能已经注意到,我们必须强制解包 item.timestamp 来在具有精度的断言方法中使用它。这是因为,与 XCTAssertEqual(_:_:) 相比,XCTAssertEqual(_:_:accuracy:) 不能比较可选值。ToDoItem 中的 timestamp 是可选的,这样就可以创建没有截止日期的待办事项。在单元测试中强制解包一个值并不像在生产代码中那样有问题,因为测试中的崩溃只对开发者可见。

然而,苹果还是为 XCTest 添加了一个功能来更好地处理可选值。这对于本书的其余部分来说非常重要,因此值得单独成章。

在单元测试中处理可选值

在 Xcode 11 中,Apple 将XCTUnwrap(_:)函数引入到XCTest中。这个函数展开其参数并返回展开后的值。如果参数是nil,这个函数会抛出一个错误。在本节中,我们将使用这个函数来改进我们的测试代码。用以下代码替换test_init_setsTimestamp()测试方法:

// ToDoItemTests.swift
func test_init_setsTimestamp() throws {
  let dummyTimestamp: TimeInterval = 42.0
  let item = ToDoItem(title: "Dummy",
                      timestamp: dummyTimestamp)
  let timestamp = try XCTUnwrap(item.timestamp)
  XCTAssertEqual(timestamp,
                 dummyTimestamp,
                 accuracy: 0.000_001)
} 

这段代码中有几处变化。让我们逐一查看:

  • 该方法现在被标记为throws。这样做的原因是我们调用了一个可能会抛出错误的函数。当一个带有throws标记的测试方法在执行过程中抛出错误而没有被捕获时,该测试方法会失败。

  • 使用try XCTUnwrap(item.timestamp),我们尝试展开item.timestamp的值。

  • 结果被分配给一个变量,该变量用于XCTAssertEqual方法。

当你在测试代码中必须处理可选值时,这是首选的方式。这样,在值意外为nil的情况下,你可以获得最有价值的信息。

添加位置属性

我们希望在ToDoItem的初始化器中设置的最后一个属性是它的Location。位置有一个名称,并且可以可选地有一个坐标。我们将使用一个结构体来封装这些数据到一个类型中。让我们开始吧:

  1. 将以下代码添加到ToDoItemTests

    // ToDoItemTests.swift
    func test_init_whenGivenLocation_setsLocation() {
      let dummyLocation = Location(name: "Dummy Name")
    }
    

测试尚未完成,但它已经失败了,因为 Xcode 中的Location还没有完成。

  1. 打开Location.swiftToDoItem结构体,我们已经知道需要什么来使测试通过。

  2. 将以下代码添加到Location.swift

    // Location.swift
    struct Location {
      let name: String
    }
    

这定义了一个名为Location的结构体,并带有name属性,使得测试代码再次可编译。但测试还没有完成。

  1. 将以下代码添加到test_init_whenGivenLocation_setsLocation()

    // ToDoItemTests.swift
    func test_init_whenGivenLocation_setsLocation() {
      let dummyLocation = Location(name: "Dummy Name")
      let item = ToDoItem(title: "Dummy Title",
                          location: dummyLocation)
      XCTAssertEqual(item.location?.name,
                     dummyLocation.name)
    }
    

不幸的是,我们目前还不能使用位置本身来检查相等性,所以下面的断言不起作用:

XCTAssertEqual(item.location, dummyLocation)

原因是XCTAssertEqual()的前两个参数必须符合Equatable协议。我们将在下一章中添加协议的符合性。

同样,这不能编译,因为ToDoItem的初始化器没有名为Location的参数。

  1. location属性和初始化器参数添加到ToDoItem中。结果应该看起来像这样:

    // ToDoItem.swift
    struct ToDoItem {
      let title: String
      let itemDescription: String?
      let timestamp: TimeInterval?
      let location: Location?
    
      init(title: String,
           itemDescription: String? = nil,
           timestamp: TimeInterval? = nil,
           location: Location? = nil) {
    
        self.title = title
        self.itemDescription = itemDescription
        self.timestamp = timestamp
        self.location = location
      }
    }
    
  2. 再次运行测试。所有测试都将通过,并且没有需要重构的地方。我们现在已经使用 TDD 实现了一个结构体来持有ToDoItem

在下一节中,我们将实现一个结构体来存储待办事项的位置数据。

实现Location结构体

在上一节中,我们添加了一个结构体来存储关于位置的信息。现在我们将添加测试以确保Location具有所需的属性和初始化器。

这些测试可以添加到ToDoItemTests中,但当测试类与实现类/结构体相匹配时,它们更容易维护。因此,我们需要一个新的测试用例类。

打开 ToDoTests 组,并添加一个名为 LocationTests 的单元测试用例类。确保您进入 iOS | | 单元测试用例类,因为我们想测试 iOS 代码,而 Xcode 有时会导航到 OS X |

设置编辑器以在左侧显示 LocationTests.swift,并在右侧的辅助编辑器中显示 Location.swift。在测试类中添加 @testable import ToDo 并删除 testExample()testPerformanceExample() 模板测试。

添加坐标属性

待办事项的位置将在应用中用于在详情中显示地图。地图上的位置可以使用 latitudelongitude 值进行存储。在以下步骤中,我们将添加一个 coordinate 属性来存储此信息:

  1. 为了驱动 Coordinate 属性的添加,我们需要一个失败的测试。对于坐标,我们将使用 Core Location 框架中的 CLLocationCoordinate2D 类型。

  2. 在现有导入语句下方导入 CoreLocation

    // LocationTests.swift
    import XCTest
    @testable import ToDo
    import CoreLocation
    
  3. 将以下测试添加到 LocationTests:

    // LocationTests.swift
    func test_init_setsCoordinate() throws {
      let coordinate = CLLocationCoordinate2D(latitude: 1,
                                              longitude: 2)
      let location = Location(name: "",
                              coordinate: coordinate)
      let resultCoordinate = try XCTUnwrap(location.coordinate)
      XCTAssertEqual(resultCoordinate.latitude, 1,
                     accuracy: 0.000_001)
      XCTAssertEqual(resultCoordinate.longitude, 2,
                     accuracy: 0.000_001)
    }
    

首先,我们创建了一个坐标并使用它创建了一个 Location 实例。然后,我们断言位置坐标的 latitudelongitude 值已被设置为正确的值。我们在 CLLocationCoordinate2D 的初始化器中使用 12 的值,因为该类还有一个不接受任何参数的初始化器(CLLocationCoordinate2D()),并将 longitudelatitude 值设置为零。我们需要确保 Location 的初始化器在测试中将坐标参数分配给其属性。

注意

你可能已经注意到我们在 XCTAssertEqual() 函数中省略了 message 参数。这是因为使用的断言已经提供了足够的信息,帮助我们了解测试中期望的内容。我们期望两个值相同。没有必要在消息中重复该信息。如果你觉得这个信息有用,你可以自己添加消息。

测试无法编译,因为 Location 还没有 coordinate 属性。类似于 ToDoItem,我们希望有一个只有 name 参数的简短初始化器。因此,我们需要自己实现初始化器,而不能使用 Swift 提供的初始化器。

  1. Location.swift 的内容替换为以下代码行:

    // Location.swift
    import Foundation
    import CoreLocation
    
    struct Location {
      let name: String
      let coordinate: CLLocationCoordinate2D?
    
      init(name: String,
           coordinate: CLLocationCoordinate2D? = nil) {
    
        self.name = ""
        self.coordinate = coordinate
      }
    }
    
  2. 现在,运行测试。所有测试都将通过。

注意,我们故意在初始化器中将 name 设置为空字符串。这是使测试通过的最简单实现。但这不是我们想要的。初始化器应该将位置名称设置为 name 参数中的值。因此,我们需要另一个测试来确保 name 设置正确。

  1. 将以下测试添加到 LocationTests:

    // LocationTests.swift
    func test_init_setsName() {
      let location = Location(name: "Dummy")
      XCTAssertEqual(location.name, "Dummy")
    }
    
  2. 运行测试以确保它失败。为了使测试通过,将 Location 的初始化器中的 self.name = "" 改为 self.name = name。再次运行测试以检查它们现在是否都通过了。测试中没有需要重构的部分,它们的实现也没有问题。

现在,Location 结构可以存储一个名称以及可选的坐标,这些坐标将用于应用的用户界面。

你可能自己问过自己,为什么我们在实现该功能时从 coordinate 属性而不是 name 属性开始?我们之所以从坐标开始,是因为这对我们来说是新领域。我们不知道如何处理测试 Double 值。有时,先处理最困难的问题可以是一种解脱。这取决于你如何编写代码。测试帮助我们迈出小步,因此有助于使困难问题更容易解决。

我想先向您展示如何测试坐标,以解决眼前的问题。如果你觉得先做简单的测试会让你感觉更好,那就去做吧。但不要写不必要的简单测试来拖延时间,避免处理困难的测试。

概述

在本章中,我们使用 TDD 创建了一个结构来存储待办事项的信息。我们了解到 TDD 意味着始终在测试代码和生产代码之间切换。此外,我们还意识到,当我们需要比较浮点数时,应该使用具有精度参数的断言方法。本章所学的内容将帮助您编写更好、更健壮的单元测试。

在下一章中,我们将构建一个结构来管理待办事项。它们需要存储在某个地方,我们还需要一种方法来添加和勾选待办事项。

练习

  1. 尝试编写一个使用 XCTAssertEqual(_:_:) 失败的测试,即使值相等,也因为比较浮点数时出现问题。提示:当你使用简单的数学函数,如加法和乘法时,你经常遇到这个问题。

  2. 使 ToDoItem 遵守 Equatable 协议,并重写断言以利用该协议。

第六章:第六章:测试、加载数据和保存数据

目前,我们有结构来存储单个待办事项的信息。一个可用的待办事项应用必须显示和管理多个待办事项。此外,当用户关闭应用并再次打开时,他们期望待办事项仍然存在。

这意味着我们的应用需要能够存储和加载待办事项列表信息的结构。

在本章中,我们将添加一个类,用于将待办事项列表存储到 iOS 设备的文件系统中,并从中加载。我们将使用 JSON 格式,因为它在 iOS 开发中是一个常见的选择。它有一个很好的好处,那就是它既易于人类阅读,也易于计算机读取。

本章的结构如下:

  • 使用 Combine 发布更改

  • 检查项目

  • 存储 ToDoItem 和加载

技术要求

本章的源代码在此处可用:github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter06.

使用 Combine 发布更改

在今天的 iOS 应用中,不同部分之间的通信通常使用苹果的 Combine 框架来实现。在 Combine 中,数据更改被发布并可以被订阅。这种设计模式有助于解耦代码并使其更容易维护。

我们将在 ToDoItemStore 中使用 Combine 来通知,例如,表格视图控制器有变化,用户界面应该使用新数据更新。

打开项目导航器并选择 ToDoItemStoreTests。导入 ToDo 模块 (@testable import ToDo) 并移除两个测试方法模板。

测试异步 Combine 代码

到目前为止,我们测试的所有代码都是同步代码。在 Combine 中发布值是异步的。为了能够测试 Combine 代码,我们需要一种方法来暂停测试并等待我们想要测试的代码执行。XCTest 提供了 XCTestExpectation 来完成这项任务。让我们看看它是如何工作的:

  1. 将以下代码添加到 ToDoItemStoreTests 中:

    // ToDoItemStoreTests.swift
    func test_add_shouldPublishChange() {
      let sut = ToDoItemStore()
    }
    

toDoItemStore,但使用 sut 使其更容易阅读,并且它还允许我们在适当的时候将测试代码复制粘贴到其他测试中。

  1. 测试尚未完成,但它已经失败了,因为 Xcode 在作用域中找不到 ToDoItemStore。再次打开项目导航器并选择 ToDoItemStore.swift。将以下类定义添加到 ToDoItemStore.swift 中:

    // ToDoItemStore.swift
    class ToDoItemStore {
    }
    

这足以使测试代码可编译。

  1. 运行测试以确保它们全部通过,然后我们可以继续编写测试。将以下代码添加到 test_add_shouldPublishChange() 中:

    // ToDoItemStoreTests.swift
    func test_add_shouldPublishChange() {
      let sut = ToDoItemStore()
      let publisherExpectation = expectation(
        description: "Wait for publisher in \(#file)"
      )
      var receivedItems: [ToDoItem] = []
      let token = sut.itemPublisher
    }
    

首先,我们创建一个ToDoItemStore实例。接下来,我们需要一个期望来等待我们的 Combine 代码的异步执行。通过description,我们告知未来的自己为什么需要这个期望。为了确定publisher是否按预期工作,我们需要在测试中订阅它并检查发布值。我们将值存储在receivedItems变量中。

最后一行是订阅发布者的开始,但我们必须暂停,因为 Xcode 抱怨说类型ToDoItemStore没有成员itemPublisher。这意味着我们需要在主目标中编写一些代码,以便再次使测试可编译。

  1. 首先,我们需要导入 Combine 框架。然后,我们可以添加发布者,如下所示:

    // ToDoItemStore.swift
    import Foundation
    import Combine
    
    class ToDoItemStore {
      var itemPublisher =
        CurrentValueSubject<[ToDoItem], Never>([])
    }
    

如果你还没有使用 Combine 或泛型,这个语法可能看起来有点奇怪。<[ToDoItem], Never>中的[ToDoItem]表示发布者发送ToDoItems数组。第二部分Never是此发布者的失败类型。Never表示此发布者不能失败。总之,CurrentValueSubject<[ToDoItem], Never>([])创建了一个发送ToDoItems数组的CurrentValueSubject发布者实例,这些数组永远不会失败。

这修复了静态分析器报告的错误。我们可以切换回测试代码。

  1. 在现有导入语句下方导入 Combine,并更改test_add_shouldPublishChange()中的代码,使其看起来像这样:

    // ToDoItemStoreTests.swift
    import Combine
    // ...
    func test_add_shouldPublishChange() {
      let sut = ToDoItemStore()
      let publisherExpectation = expectation(
        description: "Wait for publisher in \(#file)"
      )
      var receivedItems: [ToDoItem] = []
      let token = sut.itemPublisher
        .dropFirst()
        .sink { items in
          receivedItems = items
          publisherExpectation.fulfill()
        }
      let toDoItem = ToDoItem(title: "Dummy")
      sut.add(toDoItem)
    }
    

这无法编译。但在我们切换回生产代码之前,让我们看看我们在这里添加了什么:

  • 首先,我们使用dropFirst()itemPublisher中丢弃第一个发布的值。我们这样做是因为CurrentValueSubject发布者在我们订阅它时立即发布第一个当前值。但在测试中,我们只想断言更改已被发布。

  • 接下来,我们使用sink(receiveValue:)订阅发布者。发布的值传递到receivedValue参数中。你无法在代码中看到参数名称,因为我们正在使用尾随闭包语法,这在 iOS 开发中很常见。我们将接收到的值存储到receivedItems变量中。在这个时候,我们在测试中等待的异步代码已经完成。我们通过在期望上调用fulfill()来告诉测试运行器我们不再需要等待。

  • 这段代码的最后两行是我们想要测试的方法的执行。我们假设ToDoItemStore有一个add(_:)方法,允许我们将待办事项添加到项目存储中。由于我们还没有编写这个方法,Xcode 在抱怨,我们必须切换回生产代码。按照下一步进行。

  1. 将以下代码添加到ToDoItemStore中:

    // ToDoItemStore.swift
    func add(_ item: ToDoItem) {
    }
    

这使得测试再次可编译。

  1. 切换回测试代码,并在以下代码中添加最后三行到test_add_shouldPublishChange()

    // ToDoItemStoreTests.swift
    func test_add_shouldPublishChange() {
      // ... arrange ...
      let toDoItem = ToDoItem(title: "Dummy")
      sut.add(toDoItem)
      wait(for: [publisherExpectation], timeout: 1)
      token.cancel()
      XCTAssertEqual(receivedItems.first?.title,
        toDoItem.title)
    }
    

使用 wait(for:timeout:),我们告诉测试运行器在此处等待,直到 first 参数中的所有期望都得到满足。如果超时后所有期望都没有得到满足,测试将失败。接下来,我们取消发布者的订阅。如果我们省略了这一行,编译器可能会删除订阅,因为它看起来在代码的任何地方都没有被使用。在最后一行,我们比较接收到的值与我们期望的值。

最后,我们可以运行测试。正如预期的那样,我们刚刚添加的测试失败了,因为发布者还没有发布任何内容。

我们希望使用这样的断言:XCTAssertEqual(receivedItems, [toDoItem])。但目前还不行,因为 ToDoItem 没有遵守 Equatable 协议,这告诉编译器如何处理两个实例之间的相等性。我们很快就会解决这个问题。但首先,我们需要让测试再次通过绿色。

  1. 修改 ToDoItemStore 中的代码,使其看起来像这样:

    // ToDoItemStore.swift
    class ToDoItemStore {
      var itemPublisher =
        CurrentValueSubject<[ToDoItem], Never>([])
      private var items: [ToDoItem] = [] {
        didSet {
          itemPublisher.send(items)
        }
      }
    
      func add(_ item: ToDoItem) {
        items.append(item)
      }
    }
    

使用这段代码,我们在项目存储中添加了一个 private 属性来保存待办事项。每当这个属性发生变化(例如,当添加新项目时),它就会使用项目发布者进行发布。因此,在 add(_:) 方法中,我们只需要将项目追加到项目列表中。

  1. 运行测试。所有测试通过。

现在让我们解决 ToDoItem 不具有相等性的问题。

使 ToDoItem 具有相等性

使 ToDoItem 具有相等性的步骤是一个重构步骤。到目前为止,代码在没有 ToDoItem 具有相等性的情况下也能工作。但如果我们可以直接在 ToDoItems 数组上使用 XCTAssertEqual,测试的可读性将大大提高。以下步骤展示了如何做到这一点:

  1. 首先,将 Equatable 协议添加到 ToDoItem 声明中,如下所示:

    // ToDoItem.swift
    struct ToDoItem: Equatable {
      // ...
    }
    
  2. 接下来,对 Location 结构体做同样的处理:

    // Location.swift
    struct Location: Equatable {
      // …
    }
    
  3. 接下来,将以下方法添加到 Location 结构体中:

    // Location.swift
    static func == (lhs: Location, rhs: Location) -> Bool {
      if lhs.name != rhs.name {
        return false
      }
      if lhs.coordinate == nil, rhs.coordinate != nil {
        return false
      }
      if lhs.coordinate != nil, rhs.coordinate == nil {
        return false
      }
      if let lhsCoordinate = lhs.coordinate,
          let rhsCoordinate = rhs.coordinate {
        if abs(lhsCoordinate.longitude -
          rhsCoordinate.longitude) > 0.000_000_1 {
          return false
        }
        if abs(lhsCoordinate.latitude -
          rhsCoordinate.latitude) > 0.000_000_1 {
          return false
        }
      }
      return true
    }
    

如果 Swift 无法自动添加 Equatable 兼容性(例如,因为其中一个属性本身不是 Equatable),我们需要添加 == (lhs:rhs:) 类方法。这个方法看起来有点复杂,但这只是因为 coordinate 属性是可选的。因此,我们还需要考虑一个坐标为 nil 而另一个不为 nil 的情况。

  1. 现在,回到 test_add_shouldPublishChange() 并将最后的断言调用替换为以下内容:

    // ToDoItemStoreTests.swift
    XCTAssertEqual(receivedItems, [toDoItem])
    

运行测试。所有测试通过。现在的断言看起来好多了,我们确实断言了我们期望测试的结果。但是测试仍然难以阅读。所有那些 Combine 代码都分散了测试的主要目标。

让我们在测试用例中添加一个辅助函数来改进测试代码:

  1. 我们将要添加的辅助函数受到了一篇博客文章的启发(www.swiftbysundell.com/articles/unit-testing-combine-based-swift-code/)由 John Sundell 编写。在 ToDoItemStoreTests.swift 文件的末尾添加以下代码,但不要在 ToDoItemStoreTests 类内部:

    // ToDoItemStoreTests.swift
    extension XCTestCase {
      func wait<T: Publisher>(
        for publisher: T,
        afterChange change: () -> Void) throws
      -> T.Output where T.Failure == Never { 
        let publisherExpectation = expectation(
          description: "Wait for publisher in \(#file)"
        )
        var result: T.Output?
        let token = publisher
          .dropFirst()
          .sink { value in
            result = value
            publisherExpectation.fulfill()
          }
    
        change()
        wait(for: [publisherExpectation], timeout: 1)
        token.cancel() 
        let unwrappedResult = try XCTUnwrap(
          result,
          "Publisher did not publish any value"
        ) 
        return unwrappedResult
      }
    }
    

这段代码与我们之前在 test_add_shouldPublishChange() 中编写的代码类似。它经过一些修改,以便作为 XCTestCase 的扩展工作。

  1. 现在,我们可以用以下代码替换测试代码:

    // ToDoItemStoreTests.swift
    func test_add_shouldPublishChange() throws {
      let sut = ToDoItemStore()
      let toDoItem = ToDoItem(title: "Dummy")
      let receivedItems = try wait(for: sut.itemPublisher)
     {
        sut.add(toDoItem)
      }
      XCTAssertEqual(receivedItems, [toDoItem])
    }
    

这个更改使得测试易于理解。再次运行测试。所有测试仍然通过。

注意

我们刚刚添加到 XCTestCasewait 方法可以抛出错误。因此,我们不得不在方法的调用中添加 try 关键字。我们可以将调用包裹在 do-catch 块中,但有一个更好的方法。当我们将测试方法本身标记为 throws 时,测试调用期间抛出的错误会被测试运行器注册为测试失败。这再次使得测试更容易阅读和理解。在本书的整个过程中,我们将始终使用 XCTest 的这个功能,而不是在测试方法中编写 do-catch 块。

  1. 但我们如何知道我们没有破坏测试?让我们确保测试仍然可以失败。转到 ToDoItemStore 并从 add(_:) 中删除 items.append(item) 行。运行测试以确保我们更改的测试现在失败。

  2. 但现在有些奇怪。测试失败显示为灰色而不是红色。原因是失败出现在我们添加到 XCTestCasewait 函数中。为了使测试失败在函数的调用位置被报告,我们需要将函数更改为以下形式(这里只显示相关行):

    // ToDoItemStoreTests.swift
    extension XCTestCase {
      func wait<T: Publisher>(
        for publisher: T,
        afterChange change: () -> Void,
        file: StaticString = #file,
        line: Uint = #line) throws
      -> T.Output where T.Failure == Never {
    
        // …
        let unwrappedResult = try XCTUnwrap(
          result,
          "Publisher did not publish any value",
          file: file,
          line: line
        ) 
        return unwrappedResult
      }
    }
    

函数现在有两个额外的参数,fileline。它们分别设置为默认值 #file#line。然后,这些参数被用于调用 XCTUnwrap。当 XCTUnwrap 现在失败时,Xcode 使用 fileline 参数来确定这个函数被调用的位置,并在调用位置报告失败。再次运行测试以查看差异。

  1. 然后,通过添加你删除的行,再次使测试通过。

在下一节中,我们将实现一个待办事项应用的基本功能:勾选项目

勾选项目

在待办事项应用中,用户需要能够将待办事项标记为已完成。这是待办事项应用的一个重要功能,因为人们使用此类应用的部分原因是在标记待办事项为已完成时获得的满足感。

因此,我们的应用也需要这个功能。由于构建此应用的过程是由测试驱动的,我们首先为这个功能编写一个新的测试。但在我们可以添加这个功能的测试之前,我们需要考虑如何在测试中断言这个功能是否工作。这意味着我们需要一种方法来获取所有已经完成的待办事项。区分已完成和尚未完成的待办事项的最简单方法是在待办事项本身中添加一个属性。这样,我们可以根据该属性的值过滤所有待办事项。

有了这个计划,我们可以开始编写测试:

  1. 将以下方法添加到 ToDoItemStoreTests.swift

    // ToDoItemStoreTests.swift
    func test_check_shouldPublishChangeInDoneItems()
     throws {
      let sut = ToDoItemStore()
      let toDoItem = ToDoItem(title: "Dummy")
      sut.add(toDoItem)
      sut.add(ToDoItem(title: "Dummy 2"))
      let receivedItems = try wait(for: sut.itemPublisher) {
        sut.check(toDoItem)
      }
    }
    

在这个测试的前四行中,我们使用两个待办事项设置ToDoItemStore。接下来,我们等待发布者并尝试检查待办事项。Xcode 告诉我们check(_:)方法缺失。由于测试代码现在无法编译,我们需要切换到生产代码并添加check(_:)方法。

  1. 前往ToDoItemStore.swift并添加以下方法:

    // ToDoItemStore.swift
    func check(_ item: ToDoItem) {  
    }
    
  2. 运行测试以使测试代码意识到这个变化。测试失败,因为我们调用check(_:)方法时,发布者没有发布任何内容。将check方法的代码更改为以下:

    // ToDoItemStore.swift
    func check(_ item: ToDoItem) {
      items.append(ToDoItem(title: ""))
    }
    

等一下!这个check方法不应该这样做。是的,你说得对。这只是使测试在这个阶段通过的最简单代码。在 TDD 中,你应该总是编写使测试通过的最简单代码。如果你知道代码是错误的,你需要添加更多测试,直到功能真正工作。

  1. 运行测试以确认所有测试都通过。打开ToDoItemStore.swift并更改test_check_shouldPublishChangeInDoneItems()中的代码,使其看起来像这样:

    // ToDoItemStoreTests.swift
    func test_check_shouldPublishChangeInDoneItems()
     throws {
      let sut = ToDoItemStore()
      let toDoItem = ToDoItem(title: "Dummy")
      sut.add(toDoItem)
      sut.add(ToDoItem(title: "Dummy 2"))
      let receivedItems = try wait(for: sut.itemPublisher)
     {
        sut.check(toDoItem)
     }
      let doneItems = receivedItems.filter({ $0.done })
      XCTAssertEqual(doneItems, [toDoItem])
    }
    

在结束括号前的最后两行是新的。在这两行中,我们首先过滤所有完成的待办事项,然后断言结果是一个只包含我们检查的待办事项的数组。

  1. Xcode 的静态分析器告诉我们ToDoItem类型没有名为done的属性。打开ToDoItem.swift并添加此属性:

    // ToDoItem.swift
    struct ToDoItem: Equatable {
      let title: String
      let itemDescription: String?
      let timestamp: TimeInterval?
      let location: Location?
      var done = false
    
      init(title: String,
           itemDescription: String? = nil,
           timestamp: TimeInterval? = nil,
           location: Location? = nil) {
    
        self.title = title
        self.itemDescription = itemDescription
        self.timestamp = timestamp
        self.location = location
      }
    }
    

现在,代码可以编译了。运行测试。test_check_shouldPublishChangeInDoneItems()测试失败,因为过滤后的项目数组为空。这是预期的,因为我们在check(_:)中添加的代码没有检查任何项目。它只是添加了一个带有空标题的新项目。

  1. 返回ToDoItemStore.swift。我们需要将数组中的待办事项替换为我们更改了done属性为true的那个。将check方法替换为以下代码:

    // ToDoItemStore.swift
    func check(_ item: ToDoItem) {
      var mutableItem = item
      mutableItem.done = true
      if let index = items.firstIndex(of: item) {
        items[index] = mutableItem
      }
    }
    

首先,我们获取项目的可变副本。然后,我们将done改为true,最后,我们将更改后的项目替换到项目数组中。

尽管这应该使测试通过,但它仍然失败了。点击失败信息旁边的红色菱形以展开它。仔细阅读信息。测试失败是因为两个数组不相同。结果数组中的待办事项在done属性中有一个不同的值。

图 6.1 – 扩展的失败信息告诉我们项目不同

图 6.1 – 扩展的失败信息告诉我们项目不同

这是有意义的。通过添加done属性,我们改变了 Swift 确定两个待办事项是否相同的方式。这不是我们想要的。待办事项应该有一个身份。

  1. 让我们添加一个属性,为待办事项提供身份。打开ToDoItem.swift并将ToDoItem结构替换为以下代码:

    // ToDoItem.swift
    struct ToDoItem: Equatable {
      let id: UUID
      let title: String
      let itemDescription: String?
      let timestamp: TimeInterval?
      let location: Location?
      var done = false
      init(title: String,
           itemDescription: String? = nil,
           timestamp: TimeInterval? = nil,
           location: Location? = nil) {
        self.id = UUID()
        self.title = title
        self.itemDescription = itemDescription
        self.timestamp = timestamp
        self.location = location
      }
      static func == (lhs: ToDoItem, rhs: ToDoItem) ->
     Bool {
        return lhs.id == rhs.id
      }
    }
    

使用这段代码,我们添加了一个在创建项目时设置的 ID。此外,我们使用这个 id 属性来确定两个待办事项是否相同。

  1. 运行测试。所有测试都通过。

  2. 在我们继续之前,我们必须稍微清理一下测试。在测试的每一行的第一行,我们创建了一个系统测试(sut)。这段代码应该放入 setUpWithError() 中。首先,将以下属性添加到 ToDoItemStoreTests

    // ToDoItemStoreTests.swift
    var sut: ToDoItemStore!
    
  3. 接下来,将 setUpWithError()tearDownWithError() 更改为以下内容:

    // ToDoItemStoreTests.swift
    override func setUpWithError() throws {
      sut = ToDoItemStore()
    }
    override func tearDownWithError() throws {
      sut = nil
    }
    
  4. 现在,我们可以从每个测试中删除以下代码行:

    let sut = ToDoItemStore()
    

这使得测试更容易理解。

现在我们可以使用我们的 ToDoItemStore 添加和检查待办事项。但到目前为止,待办事项只会在应用程序运行期间保留在内存中。ToDoItemStore 需要将待办事项存储在某个地方,并在应用程序再次启动时立即将它们加载到内存中。在下一节中,我们将实现这一点。

存储和加载待办事项

要测试存储和加载待办事项,我们首先需要创建 ToDoItemStore 类的实例,添加一个待办事项,销毁该存储实例,然后创建一个新的实例。当我们第一次实例中添加待办事项时,所有项目都应存储在文件系统中。当创建第二个实例时,存储的项目应再次从文件系统中加载。这意味着当我们创建第二个实例后找到我们第一次实例中添加的项目时,存储和加载是有效的。

实现存储和加载

测试控制自身所需的环境是至关重要的。这意味着对于存储和加载待办事项,测试需要控制项目存储的位置。例如,如果我们使用 Core Data 来持久化待办事项,测试将负责设置一个仅用于测试的假 Core Data 存储。在我们的应用程序中,我们将待办事项存储在 JSON 文件中。因此,测试需要控制 JSON 文件存储的位置。让我们看看如何做到这一点:

  1. 将以下测试方法代码添加到 ToDoItemStore

    // ToDoItemStoreTests.swift
    func test_init_shouldLoadPreviousToDoItems() {
      var sut1: ToDoItemStore? = 
        ToDoItemStore(fileName: "dummy_store")
    }
    

在这个测试中,我们不使用 setUpWithError() 中创建的实例,因为我们需要传递要使用的存储的名称。Xcode 抱怨我们向一个不接受任何参数的调用传递了一个参数。这意味着我们必须暂停编写测试代码,并切换到生产代码。

  1. 将以下初始化器添加到 ToDoItemStore

    // ToDoItemStore.swift
    init(fileName: String = "todoitems") {
    }
    

这足以让测试再次编译。这个初始化器允许将 JSON 文件的文件名传递给 ToDoItemStorefileName 参数有一个默认值,因为在生产代码中,ToDoItemStore 类应该控制文件存储的位置。

  1. 现在我们可以编写测试代码的其余部分:

    // ToDoItemStoreTests.swift
    func test_init_shouldLoadPreviousToDoItems() {
      var sut1: ToDoItemStore? =
        ToDoItemStore(fileName: "dummy_store")
      let publisherExpectation = expectation(
        description: "Wait for publisher in \(#file)"
      )
    
      let toDoItem = ToDoItem(title: "Dummy Title")
      sut1?.add(toDoItem)
      sut1 = nil
      let sut2 = ToDoItemStore(fileName: "dummy_store")
      var result: [ToDoItem]?
      let token = sut2.itemPublisher
        .sink { value in
          result = value
          publisherExpectation.fulfill()
        }
    
      wait(for: [publisherExpectation], timeout: 1)
      token.cancel()
      XCTAssertEqual(result, [toDoItem])
    }
    

这段代码看起来有点令人畏惧,但它只包含我们已经覆盖过的概念。让我们一步一步地过一下这段代码:

  1. 我们创建了一个 ToDoItemStore 的实例和一个测试期望。

  2. 接下来,我们将一个项目添加到待办事项存储中,并通过将其设置为 nil 来销毁存储。

  3. 然后,我们创建一个新的待办事项存储库并订阅其itemPublisher。但这次,我们不会丢弃发布者中的第一个发布值。因为发布者是一个CurrentValueSubject结构,订阅者一旦订阅发布者就会立即接收到当前值。

  4. 最后,我们等待 Combine 代码的异步执行,并断言发布的物品数组包含我们添加到初始待办事项存储库中的物品。

运行测试。这个测试失败了,因为我们还没有实现存储和加载。让我们实现代码。

  1. 我们需要在属性中存储文件名,并在存储和加载项目时访问它。添加以下属性并更改初始化器以设置该属性:

    // ToDoItemStore.swift
    private let fileName: String
    init(fileName: String = "todoitems") {
      self.fileName = fileName
    }
    
  2. 下一步是将ToDoItem转换为 JSON 格式。如果类型的所有属性都可以转换为 JSON,Swift 可以为我们完成这个任务。我们作为开发者需要做的只是将Codable协议添加到类型中。更改ToDoItem的声明,使其看起来像这样:

    // ToDoItem.swift
    struct ToDoItem: Equatable, Codable {
    
  3. Xcode 抱怨 Location 属性还没有遵循 Codable。将 Location 的声明更改为以下内容:

    // Location.swift
    struct Location: Equatable, Codable {
    

现在 Xcode 告诉我们Location不遵循Codable。哦,糟糕!CLLocationCoordinate2D也不遵循Codable。我们可以自己实现这个遵循,但有一个更简单的方法。我们创建一个Coordinate结构,它具有相同的功能,但却是Codable的。使用⌘**N快捷键创建一个新的 Swift 文件,并将其命名为Coordinate。将设置为ToDo,并确保它被添加到ToDo目标中。

图 6.2 – 为坐标结构创建文件

图 6.2 – 为坐标结构创建文件

  1. 将以下代码添加到新文件中:

    // Coordinate.swift
    struct Coordinate: Codable {
      let latitude: Double
      let longitude: Double
    }
    

由于 Double 是可编码的,只包含 Double 属性的架构也是可编码的。

  1. 现在,我们可以用我们新的 Coordinate 类型替换 Location 中的 CLLocationCoordinate2D 类型:

    // Location.swift
    struct Location: Equatable, Codable {
      let name: String
      let coordinate: Coordinate?
    
      init(name: String,
           coordinate: Coordinate? = nil) {
    
        self.name = name
        self.coordinate = coordinate
      }
      // …
    }
    

通过这个更改,我们不再依赖于 Core Location,可以将其导入从Location.swift中移除。

我们已经更改了一些代码。我们如何确保我们之前实现的所有内容仍然有效?通过我们的测试!为了做到这一点,我们首先必须禁用当前失败的测试,因为存储和加载待办事项的不完整实现。我们知道这个测试仍然会失败,因为我们还没有完成实现。我们只想运行我们在开始实现存储和加载之前是绿色的测试。让我们开始吧:

  1. 前往ToDoItemStoreTests并在测试的开头添加XCTSkipIf(_:_:)的调用。注意,你还需要将throws关键字添加到测试签名中:

    // ToDoItemStoreTests.swift
    func test_init_shouldLoadPreviousToDoItems() throws {
        try XCTSkipIf(true, "Just test Coordinate change")
        // …
    }
    

通过这个调用,我们告诉测试运行器它应该跳过这个测试。当我们完成坐标类型的更改后,我们只需移除这一行代码即可再次激活测试。

  1. 运行测试以确定坐标更改是否成功或是否遗漏了某些内容。test_init_setsCoordinate()测试无法编译,因为我们更改了坐标类型。将CLLocationCoordinate2D类型替换为Coordinate并删除对CoreLocation的导入:

    // LocationTests.swift
    func test_init_setsCoordinate() throws {
      let coordinate = Coordinate(latitude: 1,
        longitude: 2)
      let location = Location(name: "",
        coordinate: coordinate)
      let resultCoordinate = try
     XCTUnwrap(location.coordinate)
      XCTAssertEqual(resultCoordinate.latitude, 1,
        accuracy: 0.000_001)
      XCTAssertEqual(resultCoordinate.longitude, 2,
        accuracy: 0.000_001)
    }
    
  2. 再次运行测试。现在所有测试都通过了。

这太棒了!我们第一次使用单元测试使重构变得更容易。我们不必检查每个文件以查看是否使用了坐标属性以及是否需要适配类型。我们只需运行测试。现在想象一下我们正在处理一个有数十万行代码的代码库。一个好的测试套件可以让你在重构代码时充满信心。

返回到ToDoItemStoreTests并删除XCTSkipIf(_:_:)调用。运行测试以确认test_init_shouldLoadPreviousToDoItems()仍然失败。

现在,由于ToDoItem符合Codable协议,我们可以在 JSON 结构中存储待办事项列表:

  1. 将以下方法添加到ToDoItemStore中:

    // ToDoItemStore.swift
    private func saveItems() {
      if let url = FileManager.default
          .urls(for: .documentDirectory,
                   in: .userDomainMask)
          .first?
          .appendingPathComponent(fileName) {
        do {
          let data = try JSONEncoder().encode(items)
          try data.write(to: url)
        } catch {
          print("error: \(error)")
        }
      }
    }
    

首先,我们获取一个文件 URL 来存储 JSON 文件。接下来,我们从待办事项中获取一个Data对象并将其写入文件 URL。由于将数据转换为 JSON 和写入文件 URL 可能会抛出错误,我们将这些调用嵌入到do-catch块中。

  1. 每当待办事项列表中的任何内容发生变化时,都需要保存项目。修改add(_:)方法,使其看起来像这样:

    // ToDoItemStore.swift
    func add(_ item: ToDoItem) {
      items.append(item)
      saveItems()
    }
    
  2. 当我们向存储中添加新项目时,我们将列表保存到文件系统中。我们还需要一个加载待办事项的方法。将以下方法添加到ToDoItemStore中:

    // ToDoItemStore.swift
    private func loadItems() {
      if let url = FileManager.default
          .urls(for: .documentDirectory,
                   in: .userDomainMask)
          .first?
          .appendingPathComponent(fileName) {
        do {
          let data = try Data(contentsOf: url)
          items = try JSONDecoder()
            .decode([ToDoItem].self, from: data)
        } catch {
          print("error: \(error)")
        }
      }
    }
    

这种方法与saveItems方法相反。一开始我们得到相同的文件 URL。然后,我们从文件系统中加载数据,并将其从 JSON 格式转换为待办事项列表。

  1. 我们在ToDoItemStore的初始化器中调用此方法:

    // ToDoItemStore.swift
    init(fileName: String = "todoitems") {
      self.fileName = fileName
      loadItems()
    }
    

这应该会使我们的测试通过。运行测试以确认。

根据您的配置,您的测试可能确实是绿色的。但很可能是ToDoItemStoreTests中的某些测试会失败。在存储和加载待办事项时,测试现在依赖于之前运行的测试。这是不好的,应该避免。当测试依赖于它们执行的顺序时,测试套件是不可靠的。测试可能在某些计算机或某些环境中失败。我们需要修复这个问题:

  1. 首先,所有测试都应该使用一个虚拟的 JSON 文件。因此,将ToDoItemStoreTests中的设置代码替换为以下内容:

    // ToDoItemStoreTests.swift
    override func setUpWithError() throws {
      sut = ToDoItemStore(fileName: "dummy_store")
    }
    
  2. 其次,我们必须在每次测试执行后删除 JSON 文件:

    // ToDoItemStoreTests.swift
    override func tearDownWithError() throws {
      sut = nil
      if let url = FileManager.default
          .urls(for: .documentDirectory,
                   in: .userDomainMask)
          .first?
          .appendingPathComponent("dummy_store") {
        try? FileManager.default.removeItem(at: url)
      }
    }
    

我知道你在想什么。我们第三次使用了代码来获取 JSON 文件的文件 URL。我们应该清理一下。不用担心,我们将在下一步做这件事。

两次运行所有测试。在第二次运行中,所有测试都应该是绿色的。

清理代码

在软件开发中,我们应该遵循 DRY 原则。DRY 代表 不要重复自己。通过复制两次创建 JSON 文件 URL 的代码,我们违反了这一原则。让我们使这段代码可重用。

选择 FileManagerExtension.swift 并确保它已添加到我们项目的目标中。

按照以下步骤删除重复的代码:

  1. 将此扩展添加到 FileManagerExtension.swift:

    // FileManagerExtension.swift
    extension FileManager {
      func documentsURL(name: String) -> URL {
        guard let documentsURL = urls(for:
          .documentDirectory,
        in: .userDomainMask).first else {
          fatalError()
        }
        return documentsURL.appendingPathComponent(name)
      }
    }
    
  2. 打开 ToDoItemStore.swift 并将 saveItems() 替换为以下实现:

    // ToDoItemStore.swift
    private func saveItems() {
      let url = FileManager.default
        .documentsURL(name: fileName)
      do {
        let data = try JSONEncoder().encode(items)
        try data.write(to: url)
      } catch {
        print("error: \(error)")
      }
    }
    

    注意

    通常,你不会直接在这里使用 FileManager 单例。在实际代码中,你更愿意通过依赖注入将这种存储类型传递给 ToDoItemStore。我们采取上述方法是为了使本书尽可能简短,并且不分散本节主题的注意力。

  3. 接下来,将 loadItems() 替换为以下代码:

    // ToDoItemStore.swift
    private func loadItems() {
      let url = FileManager.default
        .documentsURL(name: fileName)
      do {
        let data = try Data(contentsOf: url)
        items = try JSONDecoder()
          .decode([ToDoItem].self, from: data)
      } catch {
        print("error: \(error)")
      }
    }
    
  4. 最后,将 ToDoItemStoreTests.swift 中的 tearDownWithError() 替换为以下代码:

    // ToDoItemStoreTests.swift
    override func tearDownWithError() throws {
      sut = nil  
      let url = FileManager.default
        .documentsURL(name: "dummy_store")
      try? FileManager.default.removeItem(at: url)
    }
    

运行所有测试以确保它们仍然通过。

  1. 我们还没有完成。还缺少一个测试。当用户将待办事项标记为完成时,项目列表也应该写入文件系统。为了确保这一点,请将以下测试添加到 ToDoItemStoreTests.swift:

    func test_init_whenItemIsChecked_
     shouldLoadPreviousToDoItems() throws {
      var sut1: ToDoItemStore? =
      ToDoItemStore(fileName: "dummy_store")
      let publisherExpectation = expectation(
        description: "Wait for publisher in \(#file)"
      )
    
      let toDoItem = ToDoItem(title: "Dummy Title")
      sut1?.add(toDoItem)
      sut1?.check(toDoItem)
      sut1 = nil
      let sut2 = ToDoItemStore(fileName: "dummy_store")
      var result: [ToDoItem]?
      let token = sut2.itemPublisher
        .sink { value in
          result = value
          publisherExpectation.fulfill()
        }
    
      wait(for: [publisherExpectation], timeout: 1)
      token.cancel()
      XCTAssertEqual(result?.first?.done, true)
    }
    

这个测试看起来有点像 test_init_shouldLoadPreviousToDoItems()。但在这里,我们在销毁待办事项存储之前检查 toDoItem。此外,我们在最后断言加载的待办事项已被标记为完成。

  1. 为了使这个测试通过,在 check(_:) 方法的 if let 条件中添加对 saveItems() 的调用:

    func check(_ item: ToDoItem) {
      var mutableItem = item
      mutableItem.done = true
      if let index = items.firstIndex(of: item) {
        items[index] = mutableItem
        saveItems()
      }
    }
    

在继续之前,请确保所有测试都通过。

摘要

在本章中,我们探讨了如何测试 Combine 代码。为了使测试更容易理解,我们引入了一个辅助方法并改进了其错误信息。我们弄清楚了如何创建一个 Equatable 类型以及这如何有助于单元测试。最后,我们学习了如何测试将 JSON 文件存储到和从 iOS 设备的文件系统中读取。

使用这些技能,你应该能够为各种不同的模型场景编写测试。

在下一章中,我们将开始构建用户界面。我们将从待办事项列表开始。

练习

  1. 从测试 Combine 代码的测试中移除期望,并检查它们是否失败。

  2. 考虑一下需要做什么来检查存储的文件是否确实为 JSON 格式。你认为这样的测试有任何用处吗?

第三部分 – 视图和视图控制器

测试视图和视图控制器稍微复杂一些。但是,正如你将在本节中看到的那样,在你练习了一段时间之后,你将能够编写健壮的用户界面测试。对于我们的 SwiftUI 代码测试,我们将从第三方库中获得一些帮助。

在本节中,我们将涵盖以下章节:

  • 第七章, 构建待办事项的表格视图控制器

  • 第八章, 构建简单的详情视图

  • 第九章, 在 SwiftUI 中驱动输入视图的测试

第七章:第七章:为待办事项构建表格视图控制器

如果你曾与其他 iOS 开发者讨论过单元测试以及 iOS 应用程序的测试驱动开发,你可能已经听到过这样的观点:iOS 应用的用户界面不可测试,也不应该进行测试。许多开发者表示,在开发过程中运行应用程序并手动测试就足以检查用户界面是否正确。

这可能适用于用户界面的初始实现。在开发过程中,你经常在 iOS 模拟器或测试设备上运行应用程序,用户界面中的大多数错误和错误都非常明显。

然而,具有单元测试支持的用户界面的主要好处是能够无畏地重构不再完美的代码。作为一名开发者,你每天都在积累经验,每年,Apple 都会发布新的 API,使我们的代码更容易理解,有时也更容易编写。长期存在的应用程序需要不断重构,以保持其可管理性。

这是在 iOS 开发中为用户界面编写测试的主要论据。当你确信用户界面背后有良好的测试支持时,你可以执行极端的重构而不会破坏已测试的功能。

在本章中,我们将构建一个表格视图控制器,用于显示待办事项的信息。这个视图控制器是应用程序的主要部分,因此从它开始是一个好主意。本章的结构如下:

  • 添加待办事项的表格视图

  • 测试表格视图的数据源

  • 重构为可差异数据源

  • 展示两个部分

  • 实现表格视图的代理

在完成本章内容后,你将能够为表格视图控制器和表格视图单元格编写单元测试。

技术要求

本章的源代码在此处可用:github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter07

添加待办事项的表格视图

和往常一样,我们从测试开始。但在我们可以编写测试之前,我们需要一个新的测试类。按照以下步骤为显示待办事项的视图控制器添加一个测试类:

  1. 选择 ToDoItemsListViewControllerTests

  2. 在创建的文件中,添加 @testable import ToDo 并删除两个模板测试方法。

  3. 为待测试的系统(sut)添加一个属性:

    // ToDoItemsListViewControllerTests.swift
    class ToDoItemsListViewControllerTests: XCTestCase {
    
      var sut: ToDoItemsListViewController!
    
      override func setUpWithError() throws {
        // Put setup code here. This ...
      }
    
      override func tearDownWithError() throws {
        // Put teardown code here. This ...
      }
    }
    

Xcode 抱怨它 在作用域中找不到类型 'ToDoItemsListViewController'。这是预期的,因为我们还没有添加这个类。

  1. 字段中选择 ToDoItemsListViewController 并将其设置为 子类UIViewController。确保 也创建 XIB 文件 复选框未被勾选。

![图 7.1 – ToDoItemsListViewController 的选项

![img/Figure_7.01_B18127.jpg]

图 7.1 – ToDoItemsListViewController 的选项

ToDoItemsListViewController 类中删除所有模板代码。

  1. 我们将多次在 ToDoItemsListViewController 和其测试类之间切换。因此,同时打开这两个文件可能是个好主意。在 Xcode 中,您可以通过在项目导航器中点击 ToDoItemsListViewControllerTests.swift 文件,然后按住 Option 键并点击 ToDoItemsListViewController.swift 文件来实现。Xcode 然后在辅助编辑器中打开第二个文件。

Figure 7.2 – Test code and production code side by side in Xcode

Figure 7.02_B18127.jpg

图 7.2 – Test code and production code side by side in Xcode

  1. 在我们可以在视图控制器中测试任何内容之前,我们需要设置系统测试。将 setUpWithError() 方法替换为以下代码:

    // ToDoItemsListViewControllerTests.swift
    override func setUpWithError() throws {
      let storyboard = UIStoryboard(name: "Main", bundle: nil)
      sut = try XCTUnwrap(
        storyboard.instantiateInitialViewController()
        as? ToDoItemsListViewController
      )
      sut.loadViewIfNeeded()
    }
    

由于视图控制器将在 Main 故事板中设置,我们需要在设置代码中从故事板中加载它。请注意,对 loadViewIfNeeded() 的调用是实际加载视图。如果我们不调用该方法,视图将不会加载,并且所有出口都是 nil

  1. 为了成为一个好公民,我们还需要在每个测试后进行清理。将 tearDownWithError() 方法替换为以下代码:

    // ToDoItemsListViewControllerTests.swift
    override func tearDownWithError() throws {
      sut = nil
    }
    

从故事板加载可能会出错。让我们添加一个测试来确保这可以工作。

  1. 将以下测试添加到 ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    func test_shouldBeSetup() {
      XCTAssertNotNil(sut)
    }
    

此测试断言在从故事板加载后,系统测试不为空。

运行测试以确保设置系统测试工作。此测试在 setupWithError 中失败。

设置方法无法从故事板实例化 ToDoItemsListViewController 的实例,因为故事板中的初始视图控制器是 ViewController 类型。让我们修复它。

  1. 打开 ToDoItemsListViewController

Figure 7.3 – Changing the initial view controller in the Main storyboard

Figure 7.03_B18127.jpg

图 7.3 – 在 Main 故事板中更改初始视图控制器

再次运行测试以确认现在所有测试都通过。

最后,我们准备好在这个新的测试类中编写第一个测试。在以下步骤中,我们添加一个测试来断言视图控制器具有用于待办事项的 tableView 属性。让我们开始吧:

  1. 将以下测试方法添加到 ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    func test_shouldHaveTableView() {
      XCTAssertTrue(sut.tableView.isDescendant(of: sut.view))
    }
    

isDescendant(of:) 方法定义在 UIView 上,如果被调用的视图位于传入参数的视图的层次结构中,则返回 true。这意味着这个测试断言 tableView 被添加到 sut.view 或其子视图中。

Xcode 抱怨 ToDoItemsListViewControllertableView 属性。

  1. 在界面构建器中打开 Main.storyboard,然后通过点击工具栏中的加号按钮并拖动一个表格视图到视图控制器的视图中来打开库。

Figure 7.4 – Adding a table view to the view controller

Figure 7.04_B18127.jpg

图 7.4 – 将表视图添加到视图控制器

  1. 使用ToDoItemsListViewController类打开助手编辑器。

![Figure 7.5 – Dragging a connection from the storyboard into the class

![Figure 7.05_B18127.jpg]

图 7.5 – 从故事板拖动连接到类

  1. 将此属性的名称设置为tableView并点击连接

运行所有测试。所有测试都通过。我们已经将表视图添加到视图控制器的视图中。

表视图由数据源和代理管理。在下一节中,我们将实现表视图数据源的部分。

测试表视图的数据源

在本节中,我们将使用测试驱动开发来实现表视图数据源的部分。我们将使用传统的通过使用视图控制器作为数据源的方法。在下一节中,我们将切换到可变数据源。本节中的挑战是编写测试,使它们独立于数据源的实际实现。

但首先,我们需要谈谈测试替身。

添加测试替身

在电影行业中,替身在演员无法参与的危险场景中使用。替身必须看起来和表现得像演员。在软件测试中,我们也有类似的东西:测试替身。测试替身看起来和表现得像一段代码,但可以在测试中完全控制。例如,为了测试我们的表视图的数据源,我们需要将视图控制器与待办项存储连接起来。我们可以使用我们已实现的存储。但这样会使表视图的测试依赖于ToDoItemStore的实现。最好有一个可以用于ToDoItemsListViewController测试的ToDoItemStore测试替身。

按照以下步骤为ToDoItemStore添加测试替身:

  1. 实现ToDoItemStore测试替身的第一步是创建一个定义我们的视图控制器期望的接口的协议。将以下协议添加到ToDoItemStore.swift中:

    // ToDoItemStore.swift
    protocol ToDoItemStoreProtocol {
      var itemPublisher:
        CurrentValueSubject<[ToDoItem], Never>
          { get set }
      func check(_: ToDoItem)
    }
    

协议定义了视图控制器需要的元素。它需要订阅项目的变化,并且还需要一种检查待办项的方式。

  1. 现在我们有了协议,我们可以将协议的符合性添加到ToDoItemStore中:

    // ToDoItemStore.swift
    class ToDoItemStore: ToDoItemStoreProtocol {
      // …
    }
    
  2. 接下来,我们需要一个符合该协议的测试替身。选择ToDoItemStoreProtocolMock。用以下代码替换该文件的内容:

    // ToDoItemStoreProtocolMock.swift
    import Foundation
    import Combine
    @testable import ToDo
    
    class ToDoItemStoreProtocolMock: ToDoItemStoreProtocol {
      var itemPublisher =
        CurrentValueSubject<[ToDoItem], Never>([])
    
      var checkLastCallArgument: ToDoItem?
      func check(_ item: ToDoItem) {
        checkLastCallArgument = item
      }
    }
    

使用这个测试替身的实现,我们可以控制在视图控制器中使用的存储的行为。我们将在下一个测试中看到如何使用这个测试替身。

使用测试替身实现行数

表视图数据源需要提供两种类型的信息:首先,给定部分中的行数;其次,给定项的单元格。当然,UITableViewDataSource协议中定义了其他方法,但这些是可选的。

让我们从给定部分的行数开始。在默认情况下,表格视图中的部分数是 1。这意味着我们感兴趣的是第一和唯一部分的行数。按照以下步骤实现表格视图的正确行数:

  1. 将以下属性添加到ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    var toDoItemStoreMock: ToDoItemStoreProtocolMock!
    
  2. 接下来,在setUpWithError()中设置它,并使系统测试使用它:

    // ToDoItemsListViewControllerTests.swift
    override func setUpWithError() throws {
      let storyboard = UIStoryboard(name: "Main", bundle: nil)
      sut = try XCTUnwrap(
        storyboard.instantiateInitialViewController()
        as? ToDoItemsListViewController
      )
      toDoItemStoreMock = ToDoItemStoreProtocolMock()
      sut.toDoItemStore = toDoItemStoreMock
      sut.loadViewIfNeeded()
    }
    

此代码无法编译,因为toDoItemStoreToDoItemsListViewController中缺失。

  1. 在编辑器中打开ToDoItemsListViewController并添加缺少的属性:

    // ToDoItemsListViewController.swift 
    class ToDoItemsListViewController: UIViewController { 
    
      @IBOutlet weak var tableView: UITableView! 
      var toDoItemStore: ToDoItemStoreProtocol? 
    }
    
  2. 接下来,将以下测试添加到ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    func test_numberOfRows_whenOneItemIsSent_shouldReturnOne() {
      toDoItemStoreMock.itemPublisher
        .send([ToDoItem(title: "dummy 1")])
      let result = sut.tableView.numberOfRows(inSection: 0)
      XCTAssertEqual(result, 1)
    }
    

在这个测试中,我们使用其itemPublisher发送一个ToDoItem实例。我们期望表格视图在零区应有行。

运行所有测试以确认这个新测试失败。

  1. 目前,表格视图没有设置数据源。dataSource属性为 nil。为了使此测试通过,我们首先需要将ToDoItemsListViewController分配给表格视图的dataSource属性。将以下方法添加到ToDoItemsListViewController

    // ToDoItemsListViewController.swift
    override func viewDidLoad() {
      super.viewDidLoad()
      tableView.dataSource = self
    }
    

Xcode 抱怨ToDoItemsListViewController尚未符合UITableViewDataSource协议。

  1. 将以下扩展添加到ToDoItemsListViewController.swift

    // ToDoItemsListViewController.swift
    extension ToDoItemsListViewController: 
      UITableViewDataSource {
      func tableView(
        _ tableView: UITableView,
        numberOfRowsInSection section: Int) -> Int {
    
          return 1
        }
    
      func tableView(
        _ tableView: UITableView,
        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
          return UITableViewCell()
        }
    }
    

这是使ToDoItemsListViewController符合UITableViewDataSource协议并使测试通过的最小代码。

你可能想知道为什么我们从tableView(_:numberOfRowsInSection:)返回一个硬编码的固定值。这显然是错误的,并且不会导致应用程序正常工作。耐心点。目前,我们的任务是使测试通过,这是我们完成的事情。我们觉得这种实现是错误的只是意味着我们需要另一个测试来确保实现是正确的。

  1. 将以下测试方法添加到ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    func test_numberOfRows_whenTwoItemsAreSent_shouldReturnTwo() 
    {
      toDoItemStoreMock.itemPublisher
        .send([
          ToDoItem(title: "dummy 1"),
          ToDoItem(title: "dummy 2")
        ])
      let result = sut.tableView.numberOfRows(inSection: 0)
      XCTAssertEqual(result, 2)
    }
    

为了在不破坏任何先前测试的情况下使此测试通过,我们需要在视图控制器中处理由项目发布者发送的项目。

  1. 首先,导入 Combine 并添加两个属性,itemstoken,到ToDoItemsListViewController

    // ToDoItemsListViewController.swift
    class ToDoItemsListViewController: UIViewController {
    
      @IBOutlet weak var tableView: UITableView!
      var toDoItemStore: ToDoItemStoreProtocol?
      private var items: [ToDoItem] = []
      private var token: AnyCancellable?
    
      // …
    }
    

items属性将保留由项目发布者发送的项目,而token属性将保留订阅该发布者的订阅者的引用。如果没有订阅者的引用,Combine会在我们使用它之前将其销毁。

  1. 接下来,更改ToDoItemsListViewController中的viewDidLoad(),使其看起来像这样:

    // ToDoItemsListViewController.swift
    override func viewDidLoad() {
      super.viewDidLoad()
      tableView.dataSource = self
      token = toDoItemStore?.itemPublisher
        .sink(receiveValue: { [weak self] items in
          self?.items = items
      })
    }
    

使用此代码,我们订阅了由toDoItemStore的项目发布者发送的变化。我们将发送的项目存储在刚刚添加的items属性中。

  1. 最后,我们可以在tableView(_:numberOfRowsInSection:)中返回项目数量:

    // ToDoItemsListViewController.swift
    func tableView(
      _ tableView: UITableView,
      numberOfRowsInSection section: Int) -> Int {
        return items.count
      }
    

运行测试。所有测试都通过。

接下来,我们将使用我们的测试替身来实现表格视图的待办事项单元格。

使用测试替身来实现设置待办事项单元格

像往常一样,在实现一个新的微功能时,我们需要一个测试。按照以下步骤添加失败的测试和使测试通过的实现:

  1. 将以下测试方法添加到 ToDoItemsListViewControllerTests.swift

    // ToDoItemsListViewControllerTests.swift
    func test_cellForRowAt_shouldReturnCellWithTitle() throws 
    {
      let titleUnderTest = "dummy 1"
      toDoItemStoreMock.itemPublisher
        .send([ToDoItem(title: titleUnderTest)])
      let tableView = try XCTUnwrap(sut.tableView)
      let indexPath = IndexPath(row: 0, section: 0)
      let cell = try XCTUnwrap(
        tableView.dataSource?
          .tableView(tableView,
                     cellForRowAt: indexPath)
        as? ToDoItemCell
      )
    }
    

这不是一个完整的测试,但我们需要在这里暂停,因为 ToDoItemCell 类型尚未定义。

  1. 选择 ToDoItemCell 并将其设置为 UITableViewCell。移除类中的模板代码。

  2. 返回到 ToDoItemsListViewControllerTests 并添加测试断言,如下所示:

    // ToDoItemsListViewControllerTests.swift
    func test_cellForRowAt_shouldReturnCellWithTitle1() throws {
      let titleUnderTest = "dummy 1"
      toDoItemStoreMock.itemPublisher
        .send([ToDoItem(title: titleUnderTest)])
      let tableView = try XCTUnwrap(sut.tableView)
      let indexPath = IndexPath(row: 0, section: 0)
      let cell = try XCTUnwrap(
        tableView.dataSource?
          .tableView(tableView,
                     cellForRowAt: indexPath)
        as? ToDoItemCell
      )
      XCTAssertEqual(cell.titleLabel.text, titleUnderTest)
    }
    

在这个测试中,我们使用测试替身发布一个包含一个待办事项的列表。然后我们执行表格视图数据源中定义的 tableView(_:cellForRowAt:) 方法。返回的表格视图单元格应该显示由发布者发送的待办事项的标题。这个测试目前无法编译,因为单元格没有名为 titleLabel 的属性。

  1. 将属性添加到 ToDoItemCell

    // ToDoItemCell.swift
    class ToDoItemCell: UITableViewCell {
      let titleLabel = UILabel()
    }
    

现在测试可以编译了。运行测试以确认新的测试失败。

如果你是一个经验丰富的 iOS 开发者,你可能已经意识到这段代码还不够。标签已初始化,但尚未添加到单元格中。我们将在本节稍后修复这个问题。

  1. 我们刚刚添加的测试失败,因为表格视图的数据源没有返回 ToDoItemCell 类型的单元格。转到 ToDoItemsListViewController 并将 tableView(_:cellForRowAt:) 方法替换为以下代码:

    // ToDoItemsListViewController.swift
    func tableView(
      _ tableView: UITableView,
      cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = ToDoItemCell()
        cell.titleLabel.text = "dummy 1"
        return cell
      }
    

尽管这段代码使所有测试通过(运行测试以确认),但这几行代码有几个问题。一个问题是在 titleLabel 中的文本是硬编码为测试期望的字符串。写这样的代码可能看起来很愚蠢,但这对 TDD 来说是基本必要的。使用硬编码值使测试通过的代码告诉我们需要更多的测试。

  1. 将以下测试添加到 ToDoItemsListViewController

    // ToDoItemsListViewControllerTests.swift
    func test_cellForRowAt_shouldReturnCellWithTitle2() throws 
    {
      let titleUnderTest = "dummy 2"
      toDoItemStoreMock.itemPublisher
        .send([
          ToDoItem(title: "dummy 1"),
          ToDoItem(title: titleUnderTest)
        ])
      let tableView = try XCTUnwrap(sut.tableView)
      let indexPath = IndexPath(row: 1, section: 0)
      let cell = try XCTUnwrap(
        tableView.dataSource?
          .tableView(tableView,
                     cellForRowAt: indexPath)
        as? ToDoItemCell
      )
      XCTAssertEqual(cell.titleLabel.text, titleUnderTest)
    }
    

你应该尝试为这个测试找一个更好的名字。我在这里使用这个名字是为了让方法名尽可能短。长方法名在打印的书籍中看起来不好。

在这个测试中,我们向系统测试发送两个待办事项,并检查第二个单元格中的文本。运行测试。这个新的测试失败,因为 titleLabel 中的文本是硬编码的。

  1. 修改 tableView(_:cellForRowAt:) 的实现,使其看起来像这样:

    // ToDoItemsListViewController.swift
    func tableView(
      _ tableView: UITableView,
      cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = ToDoItemCell()
        let item = items[indexPath.row]
        cell.titleLabel.text = item.title
        return cell
      }
    

在此代码中,我们从 items 属性中获取该行的项,并将其标题分配给 titleLabel 属性的文本。运行测试以确认所有测试通过。

实现代码仍然存在问题。表格视图中的单元格应该被重用,以提高表格视图的渲染性能。让我们重构代码,同时不破坏测试。

  1. 要启用 UITableView 的单元格重用功能,请将以下代码行添加到 ToDoItemsListViewControllerviewDidLoad() 中:

    // ToDoItemsListViewController.swift
    tableView.register(
      ToDoItemCell.self,
      forCellReuseIdentifier: "ToDoItemCell"
    )
    

通过这个调用,我们将 ToDoItemCell 注册到表格视图的重用队列中。

  1. 现在,我们可以要求表格视图在tableView(_:cellForRowAt:)中出队这样的单元格:

    // ToDoItemsListViewController.swift
    func tableView(
      _ tableView: UITableView,
      cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(
          withIdentifier: "ToDoItemCell",
          for: indexPath
        ) as! ToDoItemCell
        let item = items[indexPath.row]
        cell.titleLabel.text = item.title
        return cell
      }
    

运行测试以确认我们没有破坏任何东西。

ToDoItemCell的实现不足以使单元格在用户界面中显示标题。我们已经初始化了标签,但还没有将其添加到任何视图中。这就是我们接下来要做的。

我们可以编写测试来检查标签是否在视图控制器的测试中添加到表格视图单元格。但如果我们仔细想想,它们应该属于针对单元格本身的专用测试。按照以下步骤添加测试和实现,以使测试通过:

  1. 旁边的文本字段中选择ToDoItemCellTests并创建文件。删除创建的文件中的两个模板测试。

  2. 在现有的导入语句下方,添加对ToDo模块的可测试导入:

    // ToDoItemCellTests.swift
    import XCTest
    @testable import ToDo
    
  3. 在我们可以测试与表格视图单元格相关的任何内容之前,我们需要设置它。将ToDoItemCellTests类的实现替换为以下代码:

    // ToDoItemCellTests.swift
    class ToDoItemCellTests: XCTestCase {
    
      var sut: ToDoItemCell!
    
      override func setUpWithError() throws {
        sut = ToDoItemCell()
      }
    
      override func tearDownWithError() throws {
        sut = nil
      }
    }
    
  4. 现在,我们准备好向这个新的测试类添加第一个测试。添加以下测试方法:

    // ToDoItemCellTests.swift
    func test_hasTitleLabelSubview() {
      let subview = sut.titleLabel
      XCTAssertTrue(subview.isDescendant(of: sut.contentView))
    }
    

isDescendant(of:)方法是在UIView上定义的。我们已经在本章前面编写测试时看到了这个调用,当时我们断言表格视图被添加到视图控制器的视图中。

运行测试以确认这个新的测试失败。

  1. 要使这个测试通过,请修改ToDoItemCell中的代码,使其看起来像这样:

    // ToDoItemCell.swift
    class ToDoItemCell: UITableViewCell {
      let titleLabel = UILabel()
      override init(style: UITableViewCell.CellStyle,
                    reuseIdentifier: String?) {
        super.init(style: style, 
                   reuseIdentifier: reuseIdentifier)
        contentView.addSubview(titleLabel)
      }
      required init?(coder: NSCoder) { fatalError() }
    }
    

运行测试以确认这段代码使测试通过。

  1. 测试通过后,我们进入 TDD 工作流程的重构阶段。根据你的开发风格,你可能已经对这个实现感到满意。我喜欢以不同的方式结构用户界面元素的初始化。我会将那段代码重构为以下形式:

    // ToDoItemCell.swift
    class ToDoItemCell: UITableViewCell {
    
      let titleLabel: UILabel
    
      override init(style: UITableViewCell.CellStyle,
                    reuseIdentifier: String?) {
    
        titleLabel = UILabel()
    
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)
    
        contentView.addSubview(titleLabel)
      }
    
      required init?(coder: NSCoder) { fatalError() }
    }
    

差别在于我更喜欢在init方法中初始化元素。运行测试以确认所有测试仍然通过。

注意,我们有意没有实现任何标签的位置。在我看来,这不是我们应该用单元测试来测试的事情。用户界面元素的位置和大小取决于屏幕大小和 iOS 版本。我们可以为这些值编写测试,但很可能会经常破坏,尽管应用对用户来说仍然可以工作。我们想要通过测试来捕捉真正的错误。

好的,这个测试很简单。现在让我们添加另外两个必需的标签 - dateLabellocationLabel

  1. 将此测试方法添加到ToDoItemCellTests中:

    // ToDoItemCellTests.swift
    func test_hasDateLabelSubview() {
      let subview = sut.dateLabel
      XCTAssertTrue(subview.isDescendant(of: sut.contentView))
    }
    
  2. 运行测试以确认这个新的测试失败。测试失败是因为缺少dateLabel属性。

  3. 前往ToDoItemCell并添加此属性:

    // ToDoItemCell.swift
    class ToDoItemCell: UITableViewCell {
    
      let titleLabel: UILabel
      let dateLabel: UILabel
    
      override init(style: UITableViewCell.CellStyle,
                    reuseIdentifier: String?) {
    
        titleLabel = UILabel()
        dateLabel = UILabel()
    
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)
    
        contentView.addSubview(titleLabel)
      }
    
      required init?(coder: NSCoder) { fatalError() }
    }
    

“等一下,多米尼克,”我听到你说,“你为什么没有将标签添加到内容视图中?”这是个好问题!在 TDD(测试驱动开发)中,你应该只添加使测试通过的代码。测试失败是因为标签未定义。因此,在这一步中,我们的任务是添加这个dateLabel属性。目前,我们不知道这是否足以使测试通过。根据我们之前对titleLabel的经验,我们感觉这还不够,但最好还是确认我们的感觉。

运行测试以确认测试仍然失败。它仍然失败,但这次是在断言函数调用的行。

  1. 为了使其通过,在现有的addSubview调用下面添加以下行:

    // ToDoItemCell.swift
    contentView.addSubview(dateLabel)
    

再次运行测试。现在,所有测试都通过了。

  1. 当设置待办事项的location属性时,单元格应显示location属性的名称。将以下测试添加到ToDoItemCellTests中:

    // ToDoItemCellTests.swift
    func test_hasLocationLabelSubview() {
      let subview = sut.locationLabel
      XCTAssertTrue(subview.isDescendant(of: sut.  contentView))
    }
    

运行测试以查看这个测试失败。

  1. location标签的属性添加到ToDoItemCell中:

    // ToDoItemCell.swift
    class ToDoItemCell: UITableViewCell {
    
      let titleLabel: UILabel
      let dateLabel: UILabel
      let locationLabel: UILabel
    
      override init(style: UITableViewCell.CellStyle,
                    reuseIdentifier: String?) {
    
        titleLabel = UILabel()
        dateLabel = UILabel()
        locationLabel = UILabel()
    
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)
    
        contentView.addSubview(titleLabel)
        contentView.addSubview(dateLabel)
      }
    
      required init?(coder: NSCoder) { fatalError() }
    }
    

运行测试以查看最后一个测试仍然失败,但现在是在断言函数调用的行中。

  1. 添加一行代码,将locationLabel作为子视图添加到单元格的contentView中:

    // ToDoItemCell.swift:
    contentView.addSubview(locationLabel)
    

运行所有测试以确保现在它们都通过。

下一步是填充表格视图的数据源中的标签。打开ToDoItemsListViewControllerTests并按照以下步骤将此功能添加到我们的应用中:

  1. 将以下测试添加到ToDoItemsListViewControllerTests中:

    // ToDoItemsListViewControllerTests.swift
    func test_cellForRowAt_shouldReturnCellWithDate() throws {
      let date = Date()
      toDoItemStoreMock.itemPublisher
        .send([
          ToDoItem(title: "dummy 1",
                   timestamp: date.timeIntervalSince1970)
        ])
      let tableView = try XCTUnwrap(sut.tableView)
      let indexPath = IndexPath(row: 0, section: 0)
      let cell = try XCTUnwrap(
        tableView.dataSource?
          .tableView(tableView,
                     cellForRowAt: indexPath)
        as? ToDoItemCell
      )
      XCTAssertEqual(cell.dateLabel.text,
                     sut.dateFormatter.string(from: date))
    } 
    

在这里,我们现在使用toDoItemStoreMock发送一个带有时间戳的待办事项。在assert函数中,我们使用一个尚未定义的dateFormatter属性。让我们添加这个属性以使测试编译。

  1. 前往ToDoItemsListViewController并添加以下属性:

    // ToDoItemsListViewController.swift
    let dateFormatter = DateFormatter()
    

现在测试可以编译。运行测试以确认这个新测试失败。

  1. 为了使测试通过,我们需要在tableView(_:cellForRowAt:)中设置日期标签。在cell.textLabel.text = item.title下面添加以下代码:

    // ToDoItemsListViewController.swift
    if let timestamp = item.timestamp {
      let date = Date(timeIntervalSince1970: timestamp)
      cell.dateLabel.text = dateFormatter.string(from: date)
    }
    

我们使用项目的时间戳从中生成一个日期,并请求日期格式化器提供该日期的字符串表示形式。

运行所有测试以确认所有测试都通过。

下一步是对实现进行重构。我们可以将日期字符串的生成移动到模型对象中,但这样模型对象就需要知道数据是如何呈现给用户的。这不是一个好主意。最好是将那段代码移动到视图模型中。这是一个与视图控制器连接的类,它将模型数据转换为可以在用户界面中呈现的形式。

我们将保持原样,因为对于我们的小型应用来说,在视图控制器中保留此代码是可以的。

你将在本章后面的练习中实现设置location标签。

我们现在已经实现了待办事项表视图单元格的表示和设置。有了测试,我们现在可以查看实现,看看我们是否可以改进它以更好地适应现代 iOS 开发的概念。我们在这里构建的实现是基于多年来表格视图的实现方式。在过去的几年里,出现了更好的设置表格视图的方法。

在以下部分,我们将重构我们的实现以使用可差异化的数据源。

重构为可差异化的数据源

在 iOS 13 中,Apple 引入了UITableViewDiffableDataSource类。此类管理数据变化时表格视图的更新,并且可以用作任何表格视图的数据源。在可能的情况下应使用它,因为实现表格视图的更新有点复杂,可能会导致奇怪的错误甚至崩溃。此外,设置此类数据源所需的代码通常比我们在上一节中使用的传统实现更容易阅读和推理。

按照以下步骤将我们的实现转换为使用可差异化的数据源:

  1. 可差异化的数据源使用符合Hashable协议的分区和项来管理表格视图中的数据。我们已经有了一个可以在可差异化的数据源中使用的项,即ToDoItem结构。然而,此结构尚未符合Hashable协议。为了使其符合该协议,请将以下代码添加到ToDoItem.swift中的当前ToDoItem实现之外:

    // ToDoItem.swift
    extension ToDoItem: Hashable {
      func hash(into hasher: inout Hasher) {
        hasher.combine(id)
      }
    }
    

使用此代码,我们告诉 Swift 提供的哈希器使用待办事项的 ID 来生成哈希值。待办事项的 ID 是唯一的,因此是哈希值的好基础。

  1. 接下来,我们需要一个同时符合Hashable协议的分区类型。将以下enum类型添加到ToDoItemsListViewController.swift中:

    // ToDoItemsListViewController.swift
    enum Section {
      case main
    }
    

目前这样就足够了。目前我们只需要一个分区,因为稍后我们将添加另一个分区来区分待办事项和已完成事项。

  1. 接下来,我们需要一个数据源属性。将以下属性添加到ToDoItemsListViewController中:

    // ToDoItemsListViewController.swift
    private var dataSource:
      UITableViewDiffableDataSource<Section, ToDoItem>?
    
  2. viewDidLoad()方法中,将tableView.dataSource = self代码替换为以下代码:

    // ToDoItemsListViewController.swift
    dataSource = 
    UITableViewDiffableDataSource<Section, ToDoItem>(
      tableView: tableView,
      cellProvider: { [weak self] tableView, indexPath, item   in
        let cell = tableView.dequeueReusableCell(
          withIdentifier: "ToDoItemCell",
          for: indexPath
        ) as! ToDoItemCell
        cell.titleLabel.text = item.title
        if let timestamp = item.timestamp {
          let date = Date(timeIntervalSince1970: timestamp)
          cell.dateLabel.text = self?.dateFormatter
            .string(from: date)
        }
        return cell
    })
    

使用此代码,我们为表格视图初始化一个可差异化的数据源。此初始化器的第二个参数是一个cell提供者。当表格视图需要为给定的索引路径显示cell提供者时,会调用此段代码。如您所见,此闭包内的代码与我们之前在tableView(_:cellForRowAt:)中使用的代码类似。

  1. 现在删除ToDoItemsListViewController.swift中实现UITableViewDataSource协议的扩展。

  2. UITableViewDiffableDataSource通过NSDiffableDataSourceSnapshot管理表格视图的更新。要使用新数据更新表格视图,我们需要创建一个快照并将其与新数据设置起来。将以下方法添加到ToDoItemsListViewController中:

    // ToDoItemsListViewController.swift
    private func update(with items: [ToDoItem]) {
      var snapshot =
      NSDiffableDataSourceSnapshot<Section, ToDoItem>()
      snapshot.appendSections([.main])
      snapshot.appendItems(items)
      dataSource?.apply(snapshot)
    }
    

在此方法中,我们创建一个快照并添加一个部分以及传递给该方法的项。

  1. 视图控制器从toDoItemStore的发布者接收更新。在viewDidLoad中更改订阅代码,使其看起来像这样:

    // ToDoItemsListViewController.swift
    token = toDoItemStore?.itemPublisher
      .sink(receiveValue: { [weak self] items in
        self?.items = items
        self?.update(with: items)
    })
    

除了将接收到的项分配给视图控制器的items属性外,我们在这里调用新的更新方法,该方法将新的快照应用到数据源。

运行测试。所有测试都通过了。我们现在已成功重构我们的表格视图代码以使用可比较的数据源。

我们的任务项列表应显示两个部分,一个用于任务项,一个用于已完成项。在下一节中,我们将更改快照创建以实现这一点。

展示两个部分

由于我们已经重构为可比较的数据源,支持表格视图中的两个部分相当容易。按照以下步骤实现两个部分:

  1. 和往常一样,我们需要从一个失败的测试开始。将以下测试添加到ToDoItemsListViewControllerTests中:

    // ToDoItemsListViewControllerTests.swift
    func test_numberOfSections_shouldReturnTwo() {
      var doneItem = ToDoItem(title: "dummy 2")
      doneItem.done = true
      toDoItemStoreMock.itemPublisher
        .send([ToDoItem(title: "dummy 1"),
               doneItem])
      let result = sut.tableView.numberOfSections
      XCTAssertEqual(result, 2)
    }
    

在这个测试中,我们使用toDoItemStoreMock将任务项和已完成项设置到表格视图中。测试方法的名称也应包括测试的先决条件。在书中我们使用较短的名称,因为否则代码更难阅读。你应该尝试使用更好的名称。

运行测试以确认此新测试失败。

  1. 为了支持两个部分,enum Section需要两个情况。更改enum Section的代码,使其看起来像这样:

    // ToDoItemsListViewController.swift
    enum Section {
      case todo
      case done
    }
    
  2. 最后,我们需要更改update方法,使其看起来像这样:

    // ToDoItemsListViewController.swift
    private func update(with items: [ToDoItem]) {
      var snapshot =
      NSDiffableDataSourceSnapshot<Section, ToDoItem>()
      snapshot.appendSections([.todo, .done])
      snapshot.appendItems(
        items.filter({ false == $0.done }),
        toSection: .todo)
      snapshot.appendItems(
        items.filter({ $0.done }),
        toSection: .done)
      dataSource?.apply(snapshot)
    }
    

在此代码中,我们将两个部分添加到快照中,并使用任务项的done属性来填充两个部分。

运行测试以确认此代码使新的测试通过。

表格视图的数据源现在已经完成。实现待办事项列表视图的下一步是添加代码,以响应用户在列表中选择任务项。

实现表格视图的委托

当用户在项目列表中选择一个任务项时,应在专用视图中显示任务项的详细信息。我们将在第十一章中实现不同视图之间的实际导航,使用协调器轻松导航。在本节中,我们将实现ToDoItemsListViewController中所需的代码。

按照以下步骤准备ToDoItemsListViewController以导航到详细视图:

  1. 假设我们已经有了一个委托,它将提供一个视图控制器可以调用的方法。将以下测试方法添加到ToDoItemsListViewControllerTests中:

    // ToDoItemsListViewControllerTests.swift
    func test_didSelectCellAt_shouldCallDelegate() throws {
      let delegateMock = 
        ToDoItemsListViewControllerProtocolMock()
    }
    

Xcode 告诉我们它找不到ToDoItemsListViewControllerProtocolMock类型。这个类型是为了模拟我们将在第十一章,“使用协调器轻松导航”中添加的实际代理。视图控制器应该通知代理用户已选择待办事项。让我们添加一个用于该任务的模拟对象。

  1. 保存为字段中选择ToDoItemsListViewControllerProtocolMock,然后点击创建。将创建的文件内容替换为以下内容:

    // ToDoItemsListViewControllerProtocolMock.swift
    import UIKit
    @testable import ToDo
    class ToDoItemsListViewControllerProtocolMock:
      ToDoItemsListViewControllerProtocol {    
      }
    

再次,Xcode 告诉我们缺少一个类型。这次,Xcode 对ToDoItemsListViewControllerProtocol一无所知。

  1. 前往ToDoItemsListViewController.swift并在导入语句下方添加以下协议:

    // ToDoItemsListViewController.swift
    protocol ToDoItemsListViewControllerProtocol {
      func selectToDoItem(
        _ viewController: UIViewController,
        item: ToDoItem)
    }
    
  2. 现在我们可以完成协议模拟的实现:

    // ToDoItemsListViewControllerProtocolMock.swift
    class ToDoItemsListViewControllerProtocolMock:
      ToDoItemsListViewControllerProtocol {
    
      var selectToDoItemReceivedArguments:
      (viewController: UIViewController, 
       item: ToDoItem)?
    
      func selectToDoItem(
        _ viewController: UIViewController,
        item: ToDoItem) {
    
          selectToDoItemReceivedArguments =
          (viewController, item)
        }
    }
    

这个协议模拟将接收到的参数存储在调用代理方法selectToDoItem(_:item:)时。

  1. 现在我们有了这个协议模拟,我们可以在测试中使用它:

    // ToDoItemsListViewControllerTest.swift
    func test_didSelectCellAt_shouldCallDelegate() throws {
      let delegateMock =
        ToDoItemsListViewControllerProtocolMock()
      sut.delegate = delegateMock
    }
    

我们必须在这里停止,因为sut还没有delegate属性。

  1. 将该属性添加到ToDoItemsListViewController

    // ToDoItemsListViewController.swift
    var delegate: ToDoItemsListViewControllerProtocol?
    
  2. 现在我们可以完成测试方法:

    // ToDoItemsListViewControllerTests.swift
    func test_didSelectCellAt_shouldCallDelegate() throws {
      let delegateMock =
        ToDoItemsListViewControllerProtocolMock()
      sut.delegate = delegateMock
      let toDoItem = ToDoItem(title: "dummy 1")
      toDoItemStoreMock.itemPublisher
        .send([toDoItem])
      let tableView = try XCTUnwrap(sut.tableView)
      let indexPath = IndexPath(row: 0, section: 0)
      tableView.delegate?.tableView?(
        tableView,
        didSelectRowAt: indexPath)
      XCTAssertEqual(
        delegateMock.selectToDoItemReceivedArguments?.item,
        toDoItem)
    }
    

在我们设置了系统测试的代理之后,我们使用toDoItemStoreMockitemPublisher发送一个待办事项。接下来,我们调用tableView(_:didSelectRowAt:)tableViews代理。最后,我们断言协议方法selectToDoItem(_:item:)确实被调用,并带有所选的待办事项。

运行测试以确认这个新测试失败。

  1. 要使测试通过,请将以下扩展添加到ToDoItemsListViewController.swift中,位于ToDoItemsListViewController类定义之外:

    // ToDoItemsListViewController.swift
    extension ToDoItemsListViewController:
      UITableViewDelegate {
    
      func tableView(_ tableView: UITableView,
                     didSelectRowAt indexPath: IndexPath) {
    
        let item = items[indexPath.row]
        delegate?.selectToDoItem(self, item: item)
      }
    }
    

在这个实现中,我们获取所选单元格的待办事项,并使用它调用代理方法。

  1. 要使ToDoItemsListViewController成为表格视图的代理,请在viewDidLoad()的末尾添加以下行:

    // ToDoItemsListViewController.swift
    tableView.delegate = self
    

现在,运行测试以确认所有测试都通过。

目前,我们已经完成了待办事项列表视图的实现。

摘要

在本章中,我们学习了如何测试表格视图和表格视图单元格。我们在重构代码的大部分内容时体验到了有用单元测试的价值。通过从传统的表格视图数据源切换到可变数据源,我们改进了代码和应用程序的行为,同时保持了现有的测试功能。

在下一章中,我们将使用我们获得的知识来创建详细视图及其视图控制器。

练习

  1. 使用测试驱动开发实现位置标签的设置。

  2. 尝试在 Xcode 的文档中找出如何在使用可变数据源时添加分区标题。我们将在第十一章,“使用协调器轻松导航”中实现分区标题。

第八章:第八章: 构建简单的详情视图

在 iOS 开发中,表格视图或集合视图通常只提供所展示项目的简要总结。为了了解所展示项目的所有详细信息,用户必须选择一个项目,以便它们可以重定向到详情视图。在详情视图中,用户通常可以与所展示的项目进行交互。

例如,在邮件应用中,摘要只显示发件人、主题和邮件的前几行。为了阅读完整的邮件并回复它,用户必须在详情视图中打开它。

在本章中,我们将构建待办事项的详情视图。本章结构如下:

  • 添加标签、按钮和地图

  • 填充数据

  • 检查待办事项

我们首先将用户界面元素添加到视图中。

技术要求

本章的源代码在此处可用:

github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter08

添加标签、按钮和地图

我们已经做了很多次,你可能已经猜到我们首先需要做什么。没错,我们需要一个测试用例类来对我们的测试进行测试。选择ToDoItemDetailsViewControllerTests。确保它已添加到单元测试目标:

图 8.1 – 测试用例需要添加到单元测试目标

图 8.1 – 测试用例需要添加到单元测试目标

在创建的测试用例类中移除两个模板测试,并在现有的导入语句下方添加@testable import ToDo

// ToDoItemDetailsViewControllerTests.swift
import XCTest
@testable import ToDo 
class ToDoItemDetailsViewControllerTests: XCTestCase { 
  override func setUpWithError() throws {
  } 
  override func tearDownWithError() throws {
  }
}

详情视图需要一些标签来显示待办事项的信息。让我们从标题的标签开始。按照以下步骤操作:

  1. 为待测试的系统添加以下属性:

    // ToDoItemDetailsViewControllerTests.swift
    var sut: ToDoItemDetailsViewController!
    

Xcode 抱怨它在作用域中找不到类型'ToDoItemDetailsViewController'

  1. 选择ToDoItemDetailsViewController。将其设置为UIViewController的子类:

图 8.2 – 视图控制器类的选项

图 8.2 – 视图控制器类的选项

  1. 移除创建的类中的模板代码。返回ToDoItemDetailsViewControllerTests。Xcode 应在几秒钟后移除错误。如果它没有移除,请选择产品 | 构建菜单项来编译项目。

  2. 现在我们有两个选择。一是,我们可以像上一章中为表格视图单元格所做的那样,在代码中构建用户界面。二是,我们可以使用故事板来构建用户界面。为了在本书中提供一个更全面的视角,我们将在本章中使用故事板来构建用户界面。

setUpWithError()tearDownWithError()方法替换为以下实现:

// ToDoItemDetailsViewControllerTests.swift
override func setUpWithError() throws {
  let storyboard = UIStoryboard(name: "Main", bundle:
    nil)
  sut = (storyboard.instantiateViewController(
    withIdentifier: "ToDoItemDetailsViewController")
    as! ToDoItemDetailsViewController)
  sut.loadViewIfNeeded()
}
override func tearDownWithError() throws {
  sut = nil
}

(storyboard.instantiateViewController(withIdentifier: "ToDoItemDetailsViewController") as! ToDoItemDetailsViewController) 两边的括号是必要的,以消除 Xcode 生成的警告。尝试并看看当你省略它们时 Xcode 会告诉你什么。

  1. 在设置和清理方法就绪后,我们可以编写该测试用例类的第一个测试:

    // ToDoItemDetailsViewControllerTests.swift
    func test_view_shouldHaveTitleLabel() throws {
      let subview = try XCTUnwrap(sut.titleLabel)
    }
    

这个测试尚未完成,但我们必须在这里暂停,因为 titleLabel 属性缺失。

  1. 将属性添加到 ToDoItemDetailsViewController

    // ToDoItemDetailsViewController.swift
    class ToDoItemDetailsViewController: UIViewController { 
      @IBOutlet var titleLabel: UILabel!
    }
    
  2. 现在我们可以完成测试方法:

    // ToDoItemDetailsViewControllerTests.swift
    func test_view_shouldHaveTitleLabel() throws {
      let subview = try XCTUnwrap(sut.titleLabel)
      XCTAssertTrue(subview.isDescendant(of: sut.view))
    }
    
  3. 运行所有测试以确认新的测试失败。这个测试在 setUpWithError() 中失败。点击失败消息中的红色菱形以查看问题:

![图 8.3 – 故事板中没有标识为 'ToDoItemDetailsViewController' 的视图控制器图片

图 8.3 – 故事板中没有标识为 'ToDoItemDetailsViewController' 的视图控制器

观察图 8.3 中显示的问题,我们需要在故事板中为该视图控制器添加一个新的场景。

  1. 在 Xcode 的 Interface Builder 中打开 Main.storyboard 文件,并点击工具栏中的加号 (+) 按钮:

![图 8.4 – 打开库图片

图 8.4 – 打开库

  1. 搜索 view controller 并将一个 View Controller 对象拖放到故事板中。通过选择 View | Inspectors | Identity 菜单项打开 Identity 检查器。将 ClassStoryboard ID 更改为 ToDoItemDetailsViewController

![图 8.5 – 更改场景的类和故事板 ID图片

图 8.5 – 更改场景的类和故事板 ID

再次运行测试。新的测试仍然失败,因为 titleLabel 属性是 nil

  1. 我们需要在故事板场景中添加一个标签,并将其与 IBOutlet 连接。通过选择 ToDoItemDetailsViewController 打开库:

![图 8.6 – 在待办事项详情视图控制器场景中添加的标签图片

图 8.6 – 在待办事项详情视图控制器场景中添加的标签

  1. Assistant 编辑器中打开 ToDoItemsDetailsViewController。如果它打开了另一个文件,请关闭 Xcode 并重新启动。

按住 Ctrl 键,从故事板中的标签拖动一个连接到代码中的 IBOutlet 属性:

![图 8.7 – 将场景中的标签与 IBOutlet 属性连接图片

图 8.7 – 将场景中的标签与 IBOutlet 属性连接

再次运行测试。所有测试都通过。

由于我们几乎没有编写任何代码,没有东西可以重构。

以同样的方式,你可以添加日期、位置和待办事项描述的标签。这里我们不会展示,因为它与添加标题标签的工作方式完全相同。使用不同的属性名再次按照步骤进行。为了帮助你开始,这里是为这三个新标签提供的三个测试:

// ToDoItemDetailsViewControllerTests.swift
func test_view_shouldHaveDateLabel() throws {
  let subview = try XCTUnwrap(sut.dateLabel)
  XCTAssertTrue(subview.isDescendant(of: sut.view))
}
func test_view_shouldHaveLocationLabel() throws {
  let subview = try XCTUnwrap(sut.locationLabel)
  XCTAssertTrue(subview.isDescendant(of: sut.view))
}
func test_view_shouldHaveDescriptionLabel() throws {
  let subview = try XCTUnwrap(sut.descriptionLabel)
  XCTAssertTrue(subview.isDescendant(of: sut.view))
}

逐个添加这些测试并使它们通过。但请确保任何时候只有一个失败的测试。

接下来,我们需要一个地图视图来显示待办事项的位置,如果设置了位置。按照以下步骤将其添加到视图中:

  1. 将以下测试添加到ToDoItemDetailsViewControllerTests中:

    // ToDoItemDetailsViewControllerTests.swift
    func test_view_shouldHaveMapView() throws {
      let subview = try XCTUnwrap(sut.mapView)
      XCTAssertTrue(subview.isDescendant(of: sut.view))
    }
    

运行测试以确认这个新测试失败。

  1. MapKit导入添加到ToDoItemDetailsViewController中,并为mapView视图添加一个出口:

    // ToDoItemDetailsViewController.swift
    import UIKit
    import MapKit
    
    class ToDoItemDetailsViewController: UIViewController {
      @IBOutlet var titleLabel: UILabel!
      @IBOutlet var dateLabel: UILabel!
      @IBOutlet var locationLabel: UILabel!
      @IBOutlet var descriptionLabel: UILabel!
      @IBOutlet var mapView: MKMapView!
    }
    

再次运行测试。这次仍然失败,但这次是因为mapView属性是nil

  1. 打开Main.storyboard并将地图视图的IBOutlet属性拖动出来。

运行测试以确认所有测试现在都通过。

我们最后要添加到视图中的 UI 元素是标记项目为完成的按钮。这与之前添加标签的方式相同。所以,这又是一个留给你的练习。以下是一个帮助你开始的测试:

// ToDoItemDetailsViewControllerTests.swift
func test_view_shouldHaveDoneButton() throws {
  let subview = try XCTUnwrap(sut.doneButton)
  XCTAssertTrue(subview.isDescendant(of: sut.view))
}

通过向视图控制器添加一个UIButton实例来使这个测试通过。

在我们继续之前,你应该花些时间使这个用户界面更好。移动元素并添加布局约束,使用户界面更吸引人。当你完成时,你的结果可能看起来类似于以下图示:

图 8.8 – 详情视图的用户界面

图 8.8 – 详情视图的用户界面

现在我们有了详情视图的用户界面,当详情被推送到屏幕上时,我们可以展示待办事项的数据。这就是我们在下一节将要实现的内容。

填充数据

按照以下步骤更新用户界面,使用待办事项的数据:

  1. 我们从一个新的测试开始。将以下测试方法添加到ToDoItemDetailsViewControllerTests中:

    // TodoItemDetailsViewControllerTests.swift
    func test_settingToDoItem_shouldUpdateTitleLabel() {
      let title = "dummy title"
      let toDoItem = ToDoItem(title: title)
      sut.toDoItem = toDoItem
    }
    

在这一点上,我们从 Xcode 得到一个错误,类型为'TodoItemDetailsViewController'的值没有成员'toDoItem'

  1. 前往ToDoItemDetailsViewController并添加toDoItem属性:

    // ToDoItemDetailsViewController.swift
    class ToDoItemDetailsViewController: UIViewController {
      @IBOutlet var titleLabel: UILabel!
      @IBOutlet var dateLabel: UILabel!
      @IBOutlet var locationLabel: UILabel!
      @IBOutlet var descriptionLabel: UILabel!
      @IBOutlet var mapView: MKMapView!
      @IBOutlet var doneButton: UIButton!
      var toDoItem: ToDoItem?
    }
    
  2. 现在我们可以通过添加Assert调用来完成测试的编写:

    // ToDoItemDetailsViewControllerTests.swift
    func test_settingToDoItem_shouldUpdateTitleLabel() {
      let title = "dummy title"
      let toDoItem = ToDoItem(title: title) 
      sut.toDoItem = toDoItem  
      XCTAssertEqual(sut.titleLabel.text, title)
    }
    

运行测试以确认这个新测试失败。

  1. 返回实现代码并将属性声明替换为以下内容:

    // ToDoItemDetailsViewController.swift
    var toDoItem: ToDoItem? {
      didSet {
        titleLabel.text = toDoItem?.title
      }
    }
    

再次运行测试以确认所有测试现在都通过。

测试和其他标签的实现方式类似,留给你们作为练习。为了帮助你开始,这里有一些测试:

// ToDoItemDetailsViewControllerTests.swift
func test_settingToDoItem_shouldUpdateDateLabel() {
  let date = Date()
  let toDoItem = ToDoItem(
    title: "dummy title",
    timestamp: date.timeIntervalSince1970)

  sut.toDoItem = toDoItem

  XCTAssertEqual(sut.dateLabel.text,
                 sut.dateFormatter.string(from: date))
}
func test_settingToDoItem_shouldUpdateDescriptionLabel() {
  let description = "dummy discription"
  let toDoItem = ToDoItem(
    title: "dummy title",
    itemDescription: description)

  sut.toDoItem = toDoItem

  XCTAssertEqual(sut.descriptionLabel.text, description)
}
func test_settingToDoItem_shouldUpdateLocationLabel() {
  let location = "dummy location"
  let toDoItem = ToDoItem(
    title: "dummy title",
    location: Location(name: location))

  sut.toDoItem = toDoItem

  XCTAssertEqual(sut.locationLabel.text, location)
}

逐个使这些测试通过。确保你永远不会同时有一个失败的测试。

当待办事项包含一个坐标的位置时,地图视图应该显示待办事项的位置地图。按照以下步骤添加此功能:

  1. 将以下测试添加到ToDoItemDetailsViewControllerTests中:

    // ToDoItemDetailsViewControllerTests.swift
    func test_settingToDoItem_shouldUpdateMapView() {
      let latitude = 51.225556
      let longitude = 6.782778
      let toDoItem = ToDoItem(
        title: "dummy title",
        location: Location(
          name: "dummy location",
          coordinate: Coordinate(latitude: latitude,
            longitude: longitude)))
    
      sut.toDoItem = toDoItem
    
      let center = sut.mapView.centerCoordinate
      XCTAssertEqual(center.latitude,
        latitude,
        accuracy: 0.000_01)
      XCTAssertEqual(center.longitude,
        longitude,
        accuracy: 0.000_01)
    }
    

通过这个测试,我们测试地图视图的中心坐标是否设置为待办事项位置坐标。

运行所有测试以确认这个新测试失败。

  1. 要使此测试通过,请将以下代码添加到ToDoItemDetailsViewControllertoDoItem属性的didSet处理程序:

    // ToDoItemDetailsViewController.swift
    if let coordinate = toDoItem?.location?.coordinate {
      mapView.setCenter(
        CLLocationCoordinate2D(
          latitude: coordinate.latitude,
          longitude: coordinate.longitude),
        animated: false)
    }
    

再次运行测试以确认所有测试现在都通过。

当展示的任务项已经完成时,完成按钮应该被禁用。按照以下步骤实现此功能:

  1. 将以下测试添加到ToDoItemDetailsViewControllerTests

    // ToDoItemDetailsViewControllerTests.swift
    func test_settingToDoItem_shouldUpdateButtonState() {
      var toDoItem = ToDoItem(title: "dummy title")
      toDoItem.done = true
    
      sut.toDoItem = toDoItem
    
      XCTAssertFalse(sut.doneButton.isEnabled)
    }
    

运行测试以确认这个新测试失败。

  1. 要使这个新测试通过,请将以下代码添加到toDoItem属性的didSet处理程序:

    // ToDoItemDetailsViewController.swift
    doneButton.isEnabled = false
    

此代码使测试通过。通过运行所有测试来尝试它。但这一行代码显然是错误的,因为它禁用了所有待办事项的完成按钮,甚至对于那些尚未完成的待办事项也是如此。为了修复这个错误,我们需要另一个测试。

  1. 将以下测试添加到ToDoItemDetailsViewControllerTests

    // ToDoItemDetailsViewControllerTests.swift
    func test_settingToDoItem_whenItemNotDone_
     shouldUpdateButtonState() {
      let toDoItem = ToDoItem(title: "dummy title")
    
      sut.toDoItem = toDoItem
    
      XCTAssertTrue(sut.doneButton.isEnabled)
    }
    

运行测试。这个新测试失败了。

  1. 要使其通过,将doneButton.isEnabled = false行替换为以下代码:

    // ToDoItemDetailsViewController.swift
    doneButton.isEnabled = (toDoItem?.done == false)
    

再次运行所有测试以确认此代码修复了错误。

太好了!我们已经完成了使用任务项的信息更新用户界面。在下一节中,我们将实现完成按钮的功能。

检查任务项

当用户点击完成按钮时,我们的应用程序必须告诉待办事项存储库将项目状态更改为完成。按照以下步骤实现此功能:

  1. 将以下测试方法添加到ToDoItemDetailsViewControllerTests

    // ToDoItemDetailsViewControllerTest.swift
    func test_sendingButtonAction_shouldCheckItem() {
      let toDoItem = ToDoItem(title: "dummy title")
      sut.toDoItem = toDoItem
      let storeMock = ToDoItemStoreProtocolMock()
      sut.toDoItemStore = storeMock
    }
    

ToDoItemDetailsViewController没有toDoItemStore属性。这意味着我们必须暂停编写此测试,并首先添加此属性。

  1. 前往ToDoItemDetailsViewController并添加toDoItemStore属性:

    // ToDoItemDetailsViewController.swift
    var toDoItemStore: ToDoItemStoreProtocol?
    
  2. 现在我们可以完成测试:

    // ToDoItemDetailsViewControllerTests.swift
    func test_sendingButtonAction_shouldCheckItem() {
      let toDoItem = ToDoItem(title: "dummy title")
      sut.toDoItem = toDoItem
      let storeMock = ToDoItemStoreProtocolMock()
      sut.toDoItemStore = storeMock
    
      sut.doneButton.sendActions(for: .touchUpInside)
    
      XCTAssertEqual(storeMock.checkLastCallArgument,
        toDoItem)
    }
    

通过sut.doneButton.sendActions(for: .touchUpInside)调用,我们向toDoItemStore的目标发送了.touchUpInside动作,该动作与系统正在测试的toDoItem变量一起调用。

  1. 要将动作添加到Main.storyboard文件和ToDoItemDetailsViewController.swift文件旁边。按住Ctrl键,从完成按钮拖动连接到代码:

Figure 8.9 – 从完成按钮拖动连接到代码

Figure 8.9 – 从完成按钮拖动连接到代码

  1. 名称字段中的checkItem更改为,并将类型更改为UIButton。然后点击连接

Figure 8.10 – 按钮动作选项

Figure 8.10 – 按钮动作选项

  1. 接下来,将动作代码更改为以下内容:

    // ToDoItemDetailsViewController.swift
    @IBAction func checkItem(_ sender: UIButton) {
      if let toDoItem = toDoItem {
        toDoItemStore?.check(toDoItem)
      }
    }
    

运行所有测试以确认这使所有测试再次通过。

当项目被标记为完成时,完成按钮应该被禁用以向用户显示此任务已完成。你将在本章的练习中实现此功能。

摘要

在本章中,我们遵循测试驱动开发(TDD)的原则构建了一个简单的详情视图控制器。我们学习了如何测试使用 Storyboard 设置的视图控制器。最后,我们弄清楚了测试按钮动作需要做什么。

本章中获得的技能将帮助你在编写各种用户界面的测试时有所帮助,即使是那些更复杂的界面。你现在能够测试用户界面元素的存在及其与代码其他部分的交互。

在下一章中,我们将为使用 SwiftUI 创建的视图编写测试。为此任务,我们需要将 GitHub 上的第三方库添加到我们的测试目标中。

练习

  1. 当用户选择完成按钮以显示任务已完成时,完成按钮应被禁用,以向用户显示此操作已成功执行。实现此功能。

  2. 修改代码,以便在待办事项中没有设置坐标时隐藏地图视图。

第九章:第九章: SwiftUI 中的测试驱动输入视图

2019 年,苹果公司推出了SwiftUI作为在苹果平台上构建应用用户界面的新方法。与使用 UIKit 构建的用户界面相比,SwiftUI 视图是某种状态的功能。因此,测试此类视图可以非常简单。在测试中,我们必须设置状态并断言预期的用户界面元素是否存在。

不幸的是,负责苹果公司的工程师们认为测试用户界面没有价值。他们认为,为了证明用户界面看起来和预期一样,运行应用并检查眼睛就足够了。这可能适用于我们在这本书中构建的如此简单的应用。但是,如果你看看 App Store,你会发现大多数应用(如果不是所有应用)都要复杂得多。通常,应用由许多视图组成,其中一些只在某些罕见情况下可见。确保这些视图对所有输入值和环境参数都有效是一项大量工作。

此外,还要考虑重构。应用永远不会完成。我们总是需要改变和添加功能。工程师们如何确保所有以前的功能仍然有效?

由计算机执行的自动测试比人工测试快几个数量级。在我看来,不使用自动测试的工程师,即使是对于用户界面,也是在浪费时间和金钱。

那么,当我们想使用 SwiftUI 构建用户界面并仍然依赖测试驱动开发的优势时,我们该怎么办呢?幸运的是,GitHub 上有一个名为ViewInspector的第三方库,可以填补这一空白。在本章中,我们将把这个库添加到我们的项目中,并探讨我们如何为 SwiftUI 代码编写单元测试。

本章分为以下几部分:

  • 添加 ViewInspector 包

  • 使用 ViewInspector 测试简单视图

  • 使用 ViewInspector 测试按钮动作

让我们先从将 ViewInspector 添加到我们的测试目标开始。

技术要求

本章的源代码在此处可用:github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter09

添加 ViewInspector 包

ViewInspector 是一个开源库,你可以在 GitHub 上找到它:github.com/nalexn/ViewInspector。要将它添加到我们的项目中,请按照以下步骤操作:

  1. 在 Xcode 中选择文件 | 添加包菜单项。

  2. 在搜索字段中输入包的 URL,https://github.com/nalexn/ViewInspector

Figure 9.1 – 添加 ViewInspector 包

图 9.1 – 添加 ViewInspector 包

点击添加包

  1. Xcode 打开一个新窗口,我们可以设置要添加包的目标。选择ToDoTests目标。然后再次点击添加包

现在包已添加到 ToDoTests 目标中,我们可以在单元测试中使用它。

使用 ViewInspector 测试一个简单的视图

我们将要构建的视图将用于向项目列表添加新的待办事项。这意味着它需要所有待办事项可以包含的信息的输入字段。因此,让我们在下一小节中查看这个方面。

添加标题文本字段

和往常一样,我们从测试开始。按照以下步骤向输入视图添加一个用于待办事项标题的文本字段:

  1. 选择 ToDoItemInputViewTests。删除两个模板测试方法。

  2. 导入 ViewInspector 库和主要目标(ToDo),以便它是可测试的(@testable):

    // ToDoItemInputViewTests.swift
    import XCTest
    @testable import ToDo
    import ViewInspector
    
  3. 在我们可以为 SwiftUI 视图编写测试之前,我们首先需要使用来自 ViewInspector 库的 Inspectable 协议扩展它。在测试用例类上方添加以下行:

    // ToDoItemInputViewTests.swift
    extension ToDoItemInputView: Inspectable {}
    

在这一点上,Xcode 抱怨它 在作用域中找不到类型 'ToDoItemInputView'。这是预期的,因为我们还没有添加这个类型。

  1. 在项目导航器中选择 ToDo 组并添加一个 SwiftUI 文件:

图 9.2 – 选择 SwiftUI 模板

图 9.2 – 选择 SwiftUI 模板

  1. ToDoItemInputView.swift 放入 另存为 字段:

图 9.3 – 新文件的名称是 ToDoItemInputView.swift

图 9.3 – 新文件的名称是 ToDoItemInputView.swift

现在,测试代码中的错误已经消失,我们可以继续进行测试。

  1. 为待测系统和包含新待办事项信息的数据对象添加一个属性:

    // ToDoItemInputViewTests.swift
    var sut: ToDoItemInputView!
    var toDoItemData: ToDoItemData!
    

我们将用用户输入到视图中的数据填充 toDoitemData,当他们完成时,我们将从该数据创建一个 ToDoItem 实例。

再次,Xcode 告诉我们某些东西缺失。

  1. 选择 ToDoItemData 作为名称。

  2. 用以下代码替换此文件的内容:

    // ToDoItemData.swift
    import Foundation 
    
    class ToDoItemData: ObservableObject {
    }
    

这个新类型需要是 ObservableObject 类型,因为我们想将其用作我们 SwiftUI 视图的状态。

  1. 返回到 ToDoItemInputViewTests 类,将 setUpWithError()tearDownWithError() 替换为以下代码:

    // ToDoItemInputViewTests .swift
    override func setUpWithError() throws {
      toDoItemData = ToDoItemData()
      sut = ToDoItemInputView(data: toDoItemData)
    }
    
    override func tearDownWithError() throws {
      sut = nil
      toDoItemData = nil
    }
    
  2. 前面的代码无法编译,因为 ToDoItemInputView 的初始化器不接受任何参数。为了修复编译错误,向 ToDoItemInputView 添加以下属性:

    // ToDoItemInputView.swift
    @ObservedObject var data: ToDoItemData
    
  3. 现在,Xcode 在 ToDoItemInputView_Previews 结构中显示了一个错误,因为 ToDoItemInputView 的初始化器中缺少新属性。用以下代码替换 ToDoItemInputView_Previews 结构的内容以修复此错误:

    // ToDoItemInputView.swift
    static var previews: some View {
      ToDoItemInputView(data: ToDoItemData())
    }
    
  4. 现在,让我们回到测试用例类。将以下测试方法的片段添加到 ToDoItemInputViewTests 中:

    // ToDoItemInputViewTests.swift
    func test_titleInput_shouldSetValueInData() throws {
      let expected = "dummy title"
      try sut
        .inspect()
        .find(ViewType.TextField.self)
        .setInput(expected)
    
      let input = toDoItemData.title
    }
    

toDoItemData 类型没有 title 属性。我们将在下一步修复它。但首先,让我们尝试理解这里发生了什么。

首先,我们在待测试的系统(sut)上调用 inspect()。这是因为我们扩展了 ToDoItemInputView 的调用以符合 Inspectable 协议。在返回的类型上,我们可以调用 find 方法,该方法返回给定类型的第一个实例,在这种情况下,是 TextField 类型。在 find 调用返回的值上,我们调用 setInput(_:) 来模拟用户对该文本框的输入。

  1. ToDoItemData 添加以下 title 属性:

    // ToDoItemData.swift
    @Published var title = ""
    
  2. 使用以下 Assert 函数调用完成测试:

    // ToDoItemInputViewTests.swift
    func test_shouldAllowTitleInput() throws {
      let expected = "dummy title"
      try sut
        .inspect()
        .find(ViewType.TextField.self)
        .setInput(expected)
    
      let input = toDoItemData.title
    
      XCTAssertEqual(input, expected)
    }
    

运行测试以确认这个新测试失败。测试在尝试找到 TextField 元素的行中失败。

  1. ToDoItemInputView 中的 body 属性的内容替换为以下代码:

    // ToDoItemInputView.swift
    TextField("Title", text: $data.title)
    

再次运行测试。现在所有测试都通过了,但我们还没有看到断言失败。之前的测试失败是因为它无法在视图的主体中找到 TextField 元素。这是问题吗?可能是的。如果我们不小心,我们可能会编写一个总是通过的断言。因此,改变代码以使断言失败但其余测试通过是一个好主意。

  1. ToDoItemInputView 结构替换为以下代码:

    // ToDoItemInputView.swift
    struct ToDoItemInputView: View { 
      @ObservedObject var data: ToDoItemData
      @State var dummy: String = ""
    
      var body: some View {
        TextField("Title", text: $dummy)
      }
    }
    

在这里,我们添加了一个虚拟变量来作为 TextField 文本的绑定。运行测试以确认最后添加的测试现在在断言处失败。因为我们已经检查了断言可以失败,所以我们可以更改代码,使测试再次通过。

这很简单。在 ViewInspector 的帮助下,我们能够为待办事项标题的输入文本字段编写测试。

在下一节中,我们将添加一个 DatePicker 结构,以允许用户为待办事项添加截止日期。

添加日期选择器

标题是待办事项的唯一必需数据。日期是可选的。在输入视图的用户界面中,我们希望使用 DatePicker 结构来输入日期值。当用户想要为该待办事项添加日期时,我们将使用切换按钮来显示 DatePicker 结构。

这意味着我们首先需要一个测试来断言视图最初不显示日期选择器。向 ToDoItemInputView 添加以下测试方法:

// ToDoItemInputViewTests.swift
func test_whenWithoutDate_shouldNotShowDateInput() {
  XCTAssertThrowsError(try sut
    .inspect()
    .find(ViewType.DatePicker.self))
}

使用此代码,我们断言 XCTAssertThrowsError 函数的参数中的代码抛出错误。这意味着我们测试视图中没有 DatePicker。如果 find 方法找到一个 DatePicker,则测试失败。

我们不需要做任何事情来使这个测试通过。它已经通过了。我们可以添加一个 DatePicker 来看看它是否会失败。实际上,我们将在下一步做这件事。

用于显示和隐藏日期选择器的切换元素将绑定到一个@State属性,该属性在ToDoItemInputView中定义了withDate属性。因此,切换元素的状态将反映在withDate属性的值中。从单元测试中与@State属性交互需要更改一些视图代码。我们将从以下更改开始实现日期输入:

  1. 用以下代码替换ToDoItemInputView结构:

    // ToDoItemInputView.swift
    struct ToDoItemInputView: View { 
      @ObservedObject var data: ToDoItemData
      var didAppear: ((Self) -> Void)?
    
      var body: some View {
        VStack {
          TextField("Title", text: $data.title)
        }
        .onAppear { self.didAppear?(self) }
      }
    }
    

我们在这里添加了一个名为didAppear的闭包,它在VStack结构的onAppear修改器中被调用。我们需要一个VStack结构或类似的东西,因为在这些步骤的后面,我们将向视图的主体添加更多元素。

  1. 通过这次准备,我们可以添加测试的第一个片段:

    // ToDoItemInputViewTests.swift
    func test_whenWithDate_shouldAllowDateInput() throws {
      let exp = sut.on(\.didAppear) { view in
        try view.find(ViewType.Toggle.self).tap()
        let expected = Date(timeIntervalSinceNow:
          1_000_000)
        try view
          .find(ViewType.DatePicker.self)
          .select(date: expected)
    
        let input = self.toDoItemData.date
      }
    
      ViewHosting.host(view: sut)
      wait(for: [exp], timeout: 0.1)
    }
    

我们以一个期望值开始测试方法。在这里,这是必要的,以便在测试中使@State属性的可更新性变得可行。原因在于 SwiftUI 中视图更新的实现细节。

与被测试系统进行的所有通信都必须放入我们使用sut.on(\.didAppear) {}定义的期望值的闭包中。

在关闭状态下,我们首先切换开关以使日期选择器出现。接下来,我们搜索DatePicker并尝试设置其日期。然后我们访问toDoItemDate属性的日期。测试尚未完成,但我们必须在这里暂停,因为Date属性缺失。

在闭包下方,我们要求ViewInspector库托管被测试系统。这触发了onAppear闭包,使得与@State属性一起工作成为可能。最后,我们必须等待期望得到满足。我们不必自己调用fulfill()在期望上。这是由ViewInspector库管理的。

  1. 前往ToDoItemData并添加Date属性:

    // ToDoItemData.swift
    @Published var date = Date()
    
  2. 现在,我们可以通过添加Assert函数调用来完成测试:

    // ToDoItemInputView.swift
    func test_whenWithDate_shouldAllowDateInput() throws {
      let exp = sut.on(\.didAppear) { view in
        try view.find(ViewType.Toggle.self).tap()
    
        let expected = Date(timeIntervalSinceNow:
          1_000_000)
        try view
          .find(ViewType.DatePicker.self)
          .select(date: expected)
    
        let input = self.toDoItemData.date
        XCTAssertEqual(input, expected)
      }
    
      ViewHosting.host(view: sut)
      wait(for: [exp], timeout: 0.1)
    }
    

运行所有测试以确认这个新测试失败。它失败了,因为它找不到开关。让我们在下一步添加开关。

  1. 为我们即将添加的切换状态添加以下属性:

    // ToDoItemInputView.swift
    @State var withDate = false
    
  2. 接下来,用以下代码替换计算属性body的内容:

    // ToDoItemInputView.swift
    VStack {
      TextField("Title", text: $data.title)
      Toggle("Add Date", isOn: $withDate)
    }
    .onAppear { self.didAppear?(self) }
    

现在测试失败了,因为它找不到日期选择器。

  1. 按如下方式添加日期选择器:

    // ToDoItemInputView.swift
    VStack {
      TextField("Title", text: $data.title)
      Toggle("Add Date", isOn: $withDate)
      DatePicker("Date", selection: $data.date)
    }
    .onAppear { self.didAppear?(self) }
    

现在,test_whenWithDate_shouldAllowDateInput通过了,但test_whenWithoutDate_shouldNotShowDateInput失败了。这是好事,因为我们之前从未看到这个测试失败。

  1. 为了使两个测试都通过,用以下代码替换计算属性body的代码:

    // ToDoItemInputView.swift
    var body: some View {
      VStack {
        TextField("Title", text: $data.title)
        Toggle("Add Date", isOn: $withDate)
        if withDate {
          DatePicker("Date", selection: $data.date)
        }
      }
      .onAppear { self.didAppear?(self) }
    }
    

运行所有测试以确认所有测试再次通过。

在使最后一个测试通过的过程中,我们了解了在涉及@State属性更改时我们必须做什么。这是因为当你开始为 SwiftUI 视图编写测试时,你需要知道这一点。

现在我们已经看到了如何测试@State属性的更改,让我们重构测试代码和实现,以便两者更容易理解。

改进测试代码和实现

对于我们的应用,如果我们把 withDate 属性移动到 ToDoItemData 中会更好,因为当我们尝试创建待办事项时需要这个信息。按照以下步骤将这个属性移动到 ToDoItemData

  1. 前往 ToDoItemInputTests 并将 test_whenWithDate_shouldAllowDateInput() 替换为以下实现:

    // ToDoItemInputTests.swift
    func test_whenWithDate_shouldAllowDateInput() throws {
      let expected = Date()
      try sut.inspect().find(ViewType.Toggle.self).tap()
      try sut
        .inspect()
        .find(ViewType.DatePicker.self)
        .select(date: expected)
    
      let input = toDoItemData.date
    
      XCTAssertEqual(input, expected)
    }
    

运行所有测试。现在这个测试失败了,因为我们不能以这种方式与 @State 属性交互。

  1. ToDoItemInputView 中删除 @State var withDate = false 这一行。

  2. 前往 ToDoItemData 并添加以下属性:

    // ToDoItemData.swift
    @Published var withDate = false
    
  3. 现在,在 ToDoItemInputView 中,将所有 withDate 的出现替换为 data.withDate

    // ToDoItemInputView.swift
    var body: some View {
      VStack {
        TextField("Title", text: $data.title)
        Toggle("Add Date", isOn: $data.withDate)
        if data.withDate {
          DatePicker("Date", selection: $data.date)
        }
      }
    }
    

注意,我们已经从 VStack 的闭合大括号下方移除了 .onAppear 调用。由于它不再需要,你也可以从 ToDoItemInputView 中移除 didAppear 属性。

运行所有测试以确认所有测试现在又都通过了。

我们现在有一个用于待办事项标题和日期的输入视图。接下来,我们需要一个用于项目描述的文本字段。

添加另一个文本字段

按照以下步骤向输入视图添加另一个文本字段:

  1. 前往 ToDoItemInputViewTests 并添加以下不完整的测试方法:

    // ToDoItemInputViewTests.swift
    func test_shouldAllowDescriptionInput() throws {
      let expected = "dummy description"
      try sut
        .inspect()
        .find(ViewType.TextField.self,
          where: { view in
          let label = try view
            .labelView()
            .text()
            .string()
          return label == "Description"
        })
        .setInput(expected)
      let input = toDoItemData.itemDescription
    }
    

这看起来与为我们编写的 title 属性的测试相似,但这次我们必须指定我们正在搜索哪个 TextField。我们添加了一个 where 闭包来找到带有 Description 标签文本的文本字段。在 where 闭包中,我们使用 ViewInspector 的检查功能来找到 TextFieldlabelView 的文本字符串。

  1. 测试无法编译,因为 ToDoItemData 中缺少 itemDescription 属性。按照以下代码添加该属性:

    // ToDoItemData.swift
    class ToDoItemData: ObservableObject {
      @Published var title = ""
      @Published var date = Date()
      @Published var withDate = false
      @Published var itemDescription = ""
    }
    
  2. 现在我们可以完成测试。向测试中添加以下断言:

    // ToDoItemInputViewTests.swift
    XCTAssertEqual(input, expected)
    

运行测试以确认这个新测试失败了。

  1. 前往 ToDoItemInputView 并更改 body 属性,使其看起来如下:

    // ToDoItemInputView.swift
    var body: some View {
      VStack {
        TextField("Title", text: $data.title)
        Toggle("Add Date", isOn: $data.withDate)
        if data.withDate {
          DatePicker("Date", selection: $data.date)
        }
        TextField("Description",
          text: $data.itemDescription)
      }
    }
    

运行测试以确认所有测试都通过了。

在这里,我们可以再次修改代码以查看测试断言失败,就像我们对 title 属性的测试那样。由于代码和测试代码看起来很相似,我对没有这样做就进行测试很有信心。如果你希望看到断言失败,请自己思考。

为了使对 title 属性的测试更加健壮,以抵御用户界面变化,向 test_shouldAllowTitleInput 添加一个类似的 where 闭包。

待办事项还可以有一个与之关联的位置。这意味着我们需要为位置名称添加另一个文本字段。你已经看到了如何向输入视图添加文本字段,所以这个练习留给你。使用 ToDoItemData 中的 locationName 属性和 "Location name" 作为 TextField 的标题。

在我们继续之前,让我们让用户界面看起来更美观一些。

改进用户界面

目前,文本字段和日期选择器使用的是 VStack 结构。这是最简单的方法,但不是最漂亮的方法。输入视图的用户界面目前看起来如下:

图 9.4 – 使用 VStack 时输入视图的用户界面

](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/tstdvn-ios-dev-swift-4e/img/Figure_9.04_B18127.jpg)

图 9.4 – 使用 VStack 时输入视图的用户界面

我们可以通过使用 FormSection 结构来改进用户界面。将 ToDoItemInputbody 属性代码替换为以下内容:

// ToDoItemInput.swift
var body: some View {
  Form {
    SwiftUI.Section {
      TextField("Title", text: $data.title)
      Toggle("Add Date", isOn: $data.withDate)
      if data.withDate {
        DatePicker("Date", selection: $data.date)
      }
      TextField("Description",
        text: $data.itemDescription)
    }
    SwiftUI.Section {
      TextField("Location name",
        text: $data.locationName)
    }
  }
}

在此代码中,我们必须指定我们想要在 SwiftUI 中定义 Section,因为我们已经定义了一个部分类型。使用此代码,用户界面看起来如下:

图 9.5 – 使用 Form 和 Section 改进的用户界面

](https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/tstdvn-ios-dev-swift-4e/img/Figure_9.05_B18127.jpg)

图 9.5 – 使用 Form 和 Section 改进的用户界面

这看起来好多了。运行测试以确认我们没有破坏任何东西。

在下一节中,我们将添加另一个文本字段和按钮。

添加地址文本字段和按钮

我们需要一个用于待办事项地址的另一个文本字段。利用你获得的经验,将其添加到输入表单的位置部分。为了使你的代码与 GitHub 上书籍的代码保持一致,在 ToDoItemData 中将属性命名为 addressString,并在 TextField 类型中使用标签 地址

在输入待办事项的数据后,用户可以将它添加到列表中。为此任务,我们需要用户界面中的一个按钮。按照以下步骤添加按钮:

  1. 将以下测试添加到 ToDoItemInputViewTests

    // ToDoItemInputViewTests.swift
    func test_shouldHaveASaveButton() throws {
      XCTAssertNoThrow(try sut
        .inspect()
        .find(ViewType.Button.self,
          where: { view in
          let label = try view
            .labelView()
            .text()
            .string()
          return label == "Save"
        }))
    }
    

运行测试以查看这个新测试失败。

  1. ToDoItemInputViewbody 属性的表单中添加以下代码:

    // ToDoItemInputView.swift
    SwiftUI.Section {
      Button(action: addToDoItem,
        label: {
        Text("Save")
      })
    }
    
  2. 为了使此代码编译,我们需要添加操作。在 ToDoItemInputViewbody 属性下方添加以下方法:

    // ToDoItemInputView.swift
    func addToDoItem() {
    }
    

运行所有测试以确认现在所有测试都通过。

现在我们已经有了所有需要的用户界面元素,可以继续到下一节实现将待办事项添加到项目列表中。

使用 ViewInspector 测试按钮动作

用户输入待办事项的地址。在项目的详细信息视图中,应用程序显示该位置的地图。这意味着在将项目添加到列表之前,我们需要将项目的地址转换为坐标。Apple 提供了一个 GeoCoder 类来完成这项任务。我们将在 第十章测试网络代码 中编写从 GeoCoder 类获取地址的测试。

在本章中,我们假设我们已经有了一个名为 APIClient 的类,该类使用 GeoCoder(或类似的服务)将地址转换为坐标。在测试中,我们将使用该 APIClient 类的模拟对象。按照以下步骤添加 APIClient 类的协议和一个符合该协议的模拟:

  1. 选择 APIClient.swift

  2. 将以下协议定义添加到新文件中:

    // APIClient.swift
    protocol APIClientProtocol {
      func coordinate(
        for: String,
        completion: (Coordinate?) -> Void)
    }
    

本协议定义了一个函数,该函数接受一个String实例,并使用一个Coordinate实例调用completion处理程序。

  1. 选择APIClientMock.swift

  2. 用以下代码替换该文件的内容:

    // APIClientMock.swift
    import Foundation
    @testable import ToDo
    
    class APIClientMock: APIClientProtocol { 
      var coordinateAddress: String?
      var coordinateReturnValue: Coordinate?
    
      func coordinate(
        for address: String,
        completion: (Coordinate?) -> Void) { 
          coordinateAddress = address
          completion(coordinateReturnValue)
      }
    }
    

由于有了这个模拟,我们可以编写一个测试,断言当用户点击保存按钮时,会获取coordinate实例。按照以下步骤添加该测试和使测试通过的实现:

  1. ToDoItemInputViewTests类中添加一个新的属性(apiClientMock):

    // ToDoItemInputViewTests.swift
    var apiClientMock: APIClientMock!
    
  2. setUpWithError中,初始化一个 API 客户端模拟,并将其传递给ToDoItemInputView的初始化器:

    // ToDoItemInputViewTests.swift
    override func setUpWithError() throws {
      toDoItemData = ToDoItemData()
      apiClientMock = APIClientMock()
      sut = ToDoItemInputView(
        data: toDoItemData,
        apiClient: apiClientMock)
    }
    
  3. 不要忘记在tearDownWithError中将此属性设置为nil

    // ToDoItemInputViewTests.swift
    override func tearDownWithError() throws {
      sut = nil
      toDoItemData = nil
      apiClientMock = nil
    }
    

ToDoItemInputView没有 API 客户端的属性。我们需要在继续测试之前添加它。

  1. apiClient属性添加到ToDoItemInputView中:

    // ToDoItemInputView.swift:
    let apiClient: APIClientProtocol
    

由于ToDoItemInputView是一个结构体,这个新属性改变了自动生成的初始化器。我们在同一文件中的ToDoItemInputView_Previews中使用这个初始化器。

  1. 用以下实现替换ToDoItemInputView_Previews结构:

    // ToDoItemInputView.swift
    struct ToDoItemInputView_Previews: PreviewProvider {
      static var previews: some View {
        ToDoItemInputView(data: ToDoItemData(),
          apiClient: APIClient())
          .previewLayout(.sizeThatFits)
      }
    }
    

使用此代码,我们用另一个错误替换了错误。缺少APIClient的实现。

  1. 将以下最小实现添加到APIClient.swift中:

    // APIClient.swift
    class APIClient: APIClientProtocol {
      func coordinate(
        for: String,
        completion: (Coordinate?) -> Void) {
      }
    }
    
  2. 现在我们可以添加测试方法:

    // ToDoItemInputViewTests.swift
    func test_saveButton_shouldFetchCoordinate() throws {
      toDoItemData.title = "dummy title"
      let expected = "dummy address"
      toDoItemData.addressString = expected
      try sut
        .inspect()
        .find(ViewType.Button.self,
              where: { view in
          let label = try view
            .labelView()
            .text()
            .string()
          return label == "Save"
        })
        .tap()
    
      XCTAssertEqual(apiClientMock.coordinateAddress,
        expected)
    }
    

在此测试中,我们设置了输入数据的标题和地址,并点击了地址的coordinate实例。

运行测试以确认这个新测试失败。

  1. ToDoItemInputView中的addToDoItem()方法替换为以下实现:

    // ToDoItemInputView.swift
    func addToDoItem() {
      apiClient.coordinate(
        for: data.addressString,
           completion: { coordinate in         
      })
    }
    

在此实现中,我们调用了在APIClientProtocol中定义的coordinate(for:completion:)方法。

运行测试以确认所有测试现在都通过。

  1. 但如果用户没有在输入表单中添加地址怎么办?在这种情况下,不应调用coordinate(for:completion:)方法,因为没有要获取的坐标。我们需要为此情况添加一个测试。将以下测试添加到ToDoInputViewTests.swift中:

    // ToDoInputViewTests.swift
    func test_save_whenAddressEmpty_
      shouldNotFetchCoordinate() throws {
      toDoItemData.title = "dummy title"
      try sut
        .inspect()
        .find(ViewType.Button.self,
              where: { view in
          let label = try view
            .labelView()
            .text()
            .string()
          return label == "Save"
        })
        .tap()
    
      XCTAssertNil(apiClientMock.coordinateAddress)
    }
    

运行所有测试以确认这个新测试失败。

  1. 为了使其通过,更改addToDoItem()中的代码,使其看起来像这样:

    // ToDoItemInputView.swift
    func addToDoItem() {
      if false == data.addressString.isEmpty {
        apiClient.coordinate(
          for: data.addressString,
             completion: { coordinate in
             })
      }
    }
    

运行所有测试以确认所有测试都通过。

在获取coordinate之后,addToDoItem()方法应调用一个代理方法来通知它输入数据已完整,可以构造项目。再次,我们将添加一个delegate协议来定义代理对象的接口。这有助于我们在测试中创建模拟对象。

按照以下步骤添加测试和调用带有待办事项数据的delegate协议的实现:

  1. ToDoItemInputView.swiftToDoItemInputView结构外部添加以下协议定义:

    // ToDoItemInputView.swift
    protocol ToDoItemInputViewDelegate {
      func addToDoItem(with: ToDoItemData,
        coordinate: Coordinate?)
    }
    
  2. 由于有了这个协议,我们可以在测试目标中添加一个模拟对象。在项目导航器中选择ToDoTests组,并添加一个名为ToDoItemInputViewDelegateMock.swift的 Swift 文件。将以下代码添加到这个新文件中:

    // ToDoItemInputViewDelegateMock.swift
    import Foundation
    @testable import ToDo 
    
    class ToDoItemInputViewDelegateMock:
      ToDoItemInputViewDelegate { 
    
      var lastToDoItemData: ToDoItemData?
      var lastCoordinate: Coordinate? 
    
      func addToDoItem(with data: ToDoItemData,
        coordinate: Coordinate?) { 
    
        lastToDoItemData = data
        lastCoordinate = coordinate
      }
    }
    
  3. 现在我们可以开始测试了。将以下测试片段添加到 ToDoItemInputViewTests

    // ToDoItemInputViewTests.swift
    func test_save_shouldCallDelegate() throws {
      toDoItemData.title = "dummy title"
      toDoItemData.addressString = "dummy address"
      apiClientMock.coordinateReturnValue =
      Coordinate(latitude: 1, longitude: 2)
      let delegateMock = ToDoItemInputViewDelegateMock()
      sut.delegate = delegateMock
    }
    

在这里,我们设置了 apiClientMock 属性,当调用 coordinate(for:completion:) 时返回一个虚拟坐标,并创建一个 ToDoItemInputViewDelegateMock 实例,并将其设置为正在测试的系统中的 delegate 属性。这个属性仍然缺失,因此,我们必须暂停编写测试,首先将其添加到 ToDoItemInputView

  1. 将以下 delegate 属性添加到 ToDoItemInputView

    // ToDoItemInputView.swift
    var delegate: ToDoItemInputViewDelegate?
    

这次更改使得测试可以编译,我们可以继续编写测试。

  1. 完成测试方法,使其看起来如下:

    // ToDoItemInputViewTests.swift
    func test_save_shouldCallDelegate() throws {
      toDoItemData.title = "dummy title"
      toDoItemData.addressString = "dummy address"
      apiClientMock.coordinateReturnValue =
      Coordinate(latitude: 1, longitude: 2)
      let delegateMock = ToDoItemInputViewDelegateMock()
      sut.delegate = delegateMock
      try sut
        .inspect()
        .find(ViewType.Button.self,
              where: { view in
          let label = try view
            .labelView()
            .text()
            .string()
          return label == "Save"
        })
        .tap()
    
      XCTAssertEqual(delegateMock.lastToDoItemData?.title,
        "dummy title")
      XCTAssertEqual(delegateMock.lastCoordinate?
        .latitude, 1)
      XCTAssertEqual(delegateMock.lastCoordinate?
        .longitude, 2)
    }
    

通常,我尽量将所有相关代码放在测试中。但在这个例子中,测试方法有点杂乱。作为一个例子,让我们将检查器代码移动到一个方法中。

  1. ToDoItemInputViewTests.swiftToDoItemInputViewTests 类下方添加以下扩展:

    // ToDoItemInputViewTests.swift
    extension ToDoItemInputView {
      func tapButtonWith(name: String) throws {
        try inspect()
          .find(ViewType.Button.self,
            where: { view in
            let label = try view
              .labelView()
              .text()
              .string()
            return label == name
          })
          .tap()
      }
    }
    
  2. 通过这个扩展,我们可以将最后一个测试编写如下:

    // ToDoItemInputViewTests.swift
    func test_save_shouldCallDelegate() throws {
      toDoItemData.title = "dummy title"
      toDoItemData.addressString = "dummy address"
      apiClientMock.coordinateReturnValue =
      Coordinate(latitude: 1, longitude: 2)
      let delegateMock = ToDoItemInputViewDelegateMock()
      sut.delegate = delegateMock
      try sut.tapButtonWith(name: "Save")
    
      XCTAssertEqual(delegateMock.lastToDoItemData?.title,
        "dummy title")
      XCTAssertEqual(delegateMock.lastCoordinate?
        .latitude, 1)
      XCTAssertEqual(delegateMock.lastCoordinate?
        .longitude, 2)
    }
    

在这种情况下,这比原始版本好一些。

  1. 要使这个测试通过,将 addToDoItem() 的实现替换为以下代码:

    // ToDoItemInputView.swift
    func addToDoItem() {
      if false == data.addressString.isEmpty {
        apiClient.coordinate(
          for: data.addressString,
             completion: { coordinate in
               self.delegate?.addToDoItem(
                with: data,
                coordinate: coordinate)
             })
      }
    }
    

在完成闭包中,我们现在调用 addToDoItem(with:coordinate:) 方法。

运行测试以确认这次更改使所有测试通过。

  1. 但如果用户没有为待办事项添加地址怎么办?添加以下测试以确保在这种情况下,delegate 方法也会被调用:

    // ToDoItemInputViewTests.swift
    func test_save_whenAddressEmpty_
      shouldCallDelegate() throws {
      toDoItemData.title = "dummy title"
      apiClientMock.coordinateReturnValue =
      Coordinate(latitude: 1, longitude: 2)
      let delegateMock = ToDoItemInputViewDelegateMock()
      sut.delegate = delegateMock
    
      try sut.tapButtonWith(name: "Save")
    
      XCTAssertEqual(delegateMock.lastToDoItemData?.title,
        "dummy title")
    }
    

运行所有测试以确认这个新测试失败。

  1. 要使这个测试通过,将 ToDoItemInputView 中的 addToDoItem() 替换为以下代码:

    // ToDoItemInputView.swift
    func addToDoItem() {
      if false == data.addressString.isEmpty {
        apiClient.coordinate(
          for: data.addressString,
             completion: { coordinate in
               self.delegate?.addToDoItem(
                with: data,
                coordinate: coordinate)
             })
      } else {
        delegate?.addToDoItem(with: data,
          coordinate: nil)
      }
    }
    

如果 address 字符串为空,我们调用 delegate 方法而不传递 coordinate 实例。

运行所有测试。所有测试再次通过。

输入视图现在已完成,我们可以继续实现一些网络代码。

摘要

测试 SwiftUI 代码的方式与测试 UIKit 代码略有不同。其中一个原因是 SwiftUI 本身的工作方式完全不同。此外,苹果没有为 SwiftUI 代码提供测试框架,因为他们认为用户界面代码应该使用 UITest 进行测试。

我认为这不是真的。UITest 解决了不同的问题。我相信你应该能够访问这两种测试,并且你应该为手头的问题选择合适的工具。

幸运的是,有了 ViewInspector,我们有一个强大的第三方解决方案来填补这个空白。在本章中,我们将它作为 SwiftUI 包添加到单元测试目标中。我们使用这个包为 SwiftUI 代码编写单元测试,并按照测试驱动开发构建了一个待办事项的输入视图。

这样,我们就学会了如何将 SwiftUI 包添加到测试目标中,以及如何使用这个特定的 SwiftUI 包来测试那些没有它难以测试的内容。

在下一章中,我们将学习如何编写网络代码的单元测试。

练习

  1. ToDoItemInputViewTests.swiftToDoItemInputView的扩展中添加更多方便的方法,使测试更容易阅读,就像我们对test_save_shouldCallDelegate()所做的那样。这些辅助方法的优势是什么?它们的缺点是什么?

  2. 当用户提供一个地址,但GeoCoder无法找到该地址的坐标时,应用应显示一个警告并询问用户是否仍想保存该项目。前往ViewInspector的 GitHub 仓库(github.com/nalexn/ViewInspector),了解如何测试警告的展示。然后编写断言警告已展示的测试,并实现该功能。

第四部分 – 网络和导航

在本书的前三部分中,我们获得了准备和实践,现在我们可以着手进行我们应用网络和导航部分的单元测试。此外,我们还将探讨如何使应用最终在 iOS 模拟器上运行并工作。

在本节中,我们将涵盖以下章节:

  • 第十章, 使用协调器进行网络代码测试

  • 第十一章, 使用协调器轻松导航

第十章:第十章:测试网络代码

几乎所有 iOS 应用程序都需要与某种服务器通信,以同步数据到其他设备或提供 iOS 设备本身无法实现的附加功能。由于服务器应用程序的代码与 iOS 应用程序的代码是分开的,因此 iOS 应用程序的单元测试不应测试服务器应用程序中实现的功能。iOS 应用程序的单元测试仅在 iOS 应用程序的代码有错误时才应失败。

为了实现这一点,单元测试需要独立于服务器应用程序。这种分离有几个优点。主要如下:

  • 当单元测试不需要等待服务器的响应时,它们运行得更快。

  • 单元测试不会因为服务器不可用而失败。

  • 即使服务器应用程序尚未可用,也可以使用测试驱动开发来开发网络代码。

在本章中,我们将使用测试驱动开发和模拟对象实现两种不同类型的网络代码。在您完成本章后,您将能够为与CLGeoCoder通信的代码编写测试。您还将学习如何使用URLSession的新异步/等待 API 编写网络代码的测试。

本章的结构如下:

  • 模拟CLGeoCoder

  • 测试与URLSession实例通信的异步/等待代码

  • 处理错误

编写网络代码的测试非常有趣,让我们开始吧。

模拟CLGeoCoder

CLGeoCoder是 Apple 提供的一个类,它可以帮助您从地址字符串获取坐标,反之亦然。CLGeoCoder中的方法基于完成闭包。在本章中,我们将探讨如何模拟和测试此类方法。

清理项目

在我们为本章编写第一个测试之前,让我们稍微清理一下项目。在项目导航器中添加部分,并根据您的结构方案将文件移动到这些部分。为了获得灵感,以下是我在主要目标中使用的结构:

![图 10.1 – 在项目导航器中添加结构图片

图 10.1 – 在项目导航器中添加结构

您的结构可以完全不同。使用您在 iOS 项目中通常使用的结构。此外,将类似的结构添加到测试目标中的文件。

当您向项目中添加新文件时,您必须根据您应用的结构选择正确的文件夹。

测试准备

在我们为在第九章中添加的APIClient类编写测试之前,即SwiftUI 中的测试驱动输入视图,我们需要一个新的测试用例类。按照以下步骤添加它:

  1. 将新的APIClientTests添加到测试目标中。删除两个测试模板方法。

  2. 使用@testable关键字导入ToDo模块,并将设置和清理代码添加到TestCase类中:

    // APIClientTests.swift
    import XCTest
    @testable import ToDo
    
    class APIClientTests: XCTestCase {
    
      var sut: APIClient!
    
      override func setUpWithError() throws {
        sut = APIClient()
      }
    
      override func tearDownWithError() throws {
        sut = nil
      }
    }
    

创建第一个测试

准备就绪后,我们可以开始编写 APIClient 类的第一个测试。按照以下步骤添加一个用于使用 CLGeoCoder 实例获取地址坐标的测试,并使其通过:

  1. 为了能够在 APIClient 类中将 CLGeoCoder 替换为模拟对象,我们需要在协议中定义我们期望的接口。将 CoreLocation 框架导入到 APIClient.swift 中,并在 APIClient 类实现之外添加以下协议定义到 APIClient.swift

    // APIClient.swift
    protocol GeoCoderProtocol {
      func geocodeAddressString(
        _ addressString: String,
        completionHandler:
        @escaping CLGeocodeCompletionHandler)
    }
    
  2. 接下来,我们需要告诉编译器 CLGeoCoder 已经符合该协议。它是这样做的,因为它已经实现了一个具有这个确切签名的方 法。在 GeoCoderProtocol 实现下方添加此行:

    // APIClient.swift
    extension CLGeocoder: GeoCoderProtocol {}
    
  3. 现在,我们可以定义一个在测试中使用的模拟对象。向测试目标添加一个新的 Swift 文件,并将其命名为 GeoCoderProtocolMock.swift。用以下内容替换其内容:

    // GeoCoderProtocolMock.swift
    import Foundation
    @testable import ToDo
    import CoreLocation
    
    class GeoCoderProtocolMock: GeoCoderProtocol { 
      var geocodeAddressString: String?
      var completionHandler: CLGeocodeCompletionHandler?
    
      func geocodeAddressString(
        _ addressString: String,
        completionHandler:
        @escaping CLGeocodeCompletionHandler) { 
          geocodeAddressString = addressString
          self.completionHandler = completionHandler
      }
    }
    
  4. 在测试中,我们想要调用 geocodeAddressString(_:completionHandler:) 方法,并将 CLPlacemark 实例传递到完成处理程序中。为了在测试中创建 CLPlacemark 实例,我们需要导入 IntentsContacts 框架,因为所需的初始化器在 Intents 框架中定义,并使用来自 Contacts 框架的类(我从 StackOverflow 的答案中得到了这个提示,该答案可在 https://stackoverflow.com/a/52932708/498796/498796 找到):

    // APIClientTests.swift
    import Intents
    import Contacts
    
  5. 现在,我们可以开始编写测试。将以下测试片段添加到 APIClientTests 中:

    // APIClientTests.swift
    func test_coordinate_fetchesCoordinate() {
      let geoCoderMock = GeoCoderProtocolMock()
      sut.geoCoder = geoCoderMock
    }
    

Xcode 告诉我们,我们需要为 geoCoder 属性在 APIClient 中添加一个属性。

  1. 前往 APIClient 类,并添加以下属性:

    // APIClient.swift
    lazy var geoCoder: GeoCoderProtocol
      = CLGeocoder()
    

lazy 关键字意味着初始化器是在属性第一次被访问时调用的。

  1. 返回到测试,使其看起来像这样:

    // APIClientTests.swift
    func test_coordinate_fetchesCoordinate() {
      let geoCoderMock = GeoCoderProtocolMock()
      sut.geoCoder = geoCoderMock
      let location = CLLocation(latitude: 1,
        longitude: 2)
      let placemark = CLPlacemark(location: location,
        name: nil,
        postalAddress: nil)
      let expectedAddress = "dummy address"
      var result: Coordinate?
      sut.coordinate(for: expectedAddress) { coordinate in
        result = coordinate
      }
      geoCoderMock.completionHandler?([placemark], nil)
      XCTAssertEqual(geoCoderMock.geocodeAddressString,
        expectedAddress)
      XCTAssertEqual(result?.latitude,
        location.coordinate.latitude)
      XCTAssertEqual(result?.longitude,
        location.coordinate.longitude)
    }
    

在我们设置了 geoCoderMock 实例之后,我们创建虚拟变量并调用我们想要测试的方法。GeoCoderProtocolMock 类捕获了 geocodeAddressString(_:completionHandler:) 调用的完成处理程序。这允许我们使用我们创建的地点标记来调用这个完成处理程序。在测试断言中,我们检查方法是否使用我们提供的地址字符串被调用,以及坐标是否传递到了 coordinate(for:completion:) 方法的 completion 闭包中。

  1. APIClient 中的 coordinate(for:completion:) 方法的实现替换为以下实现:

    // APIClient.swift
    func coordinate(
      for address: String,
      completion: (Coordinate?) -> Void) {
        geoCoder.geocodeAddressString(
          address) { placemarks, error in
          }
    }
    

注意,我们为地址字符串参数添加了一个内部名称。

运行测试。新的测试仍然失败,但第一个断言不再失败。这告诉我们我们的测试做得太多。我们应该将这个测试拆分为两个测试:一个检查方法是否使用我们提供的地址字符串被调用,另一个检查坐标是否传递到了完成闭包中。

  1. 我们应该始终只有一个失败的测试,所以将 x_ 添加到 test_coordinate_fetchesCoordinate 方法的名称前:

    // APIClientTests.swift
    func x_test_coordinate_fetchesCoordinate() {
      // …
    

由于测试运行器正在搜索以单词 test 开头的方法,添加 x_ 可以隐藏方法从测试运行器。为了确认这一点,再次运行所有测试。

  1. 现在,向 APIClientTests 添加以下测试方法:

     // APIClientTests.swift
    func test_coordinate_shouldCallGeoCoderWithAddress() {
      let geoCoderMock = GeoCoderProtocolMock()
      sut.geoCoder = geoCoderMock
      let expectedAddress = "dummy address"
      sut.coordinate(for: expectedAddress) { _ in
      }
      XCTAssertEqual(geoCoderMock.geocodeAddressString,
        expectedAddress)
    }
    
  2. APIClient 中的 coordinate(for:completion:) 方法中删除我们添加的代码,并运行测试以查看这个新测试失败。

  3. 再次添加代码并运行测试。现在所有测试都应该通过。

  4. 现在,我们可以从 test_coordinate_fetchesCoordinate() 中删除对地址字符串的检查,因为它现在已在 test_coordinate_shouldCallGeoCoderWithAddress() 中断言:

    // APIClientTests.swift
    func test_coordinate_fetchesCoordinate() {
      let geoCoderMock = GeoCoderProtocolMock()
      sut.geoCoder = geoCoderMock
      let location = CLLocation(latitude: 1,
        longitude: 2)
      let placemark = CLPlacemark(location: location,
        name: nil,
        postalAddress: nil)
      var result: Coordinate?
      sut.coordinate(for: "") { coordinate in
        result = coordinate
      }
      geoCoderMock.completionHandler?([placemark], nil)
      XCTAssertEqual(result?.latitude,
        location.coordinate.latitude)
      XCTAssertEqual(result?.longitude,
        location.coordinate.longitude)
    }
    

运行所有测试以查看这个测试失败。

  1. 为了使这个测试通过,我们需要从 CLGeoCoder 实例获取坐标并将其传递给完成处理程序。将 coordinate(for:completion:) 方法替换为以下实现:

    // APIClient.swift
    func coordinate(
      for address: String,
      completion: @escaping (Coordinate?) -> Void) {
        geoCoder.geocodeAddressString(address) { 
          placemarks, error in
          guard let clCoordinate =
            placemarks?.first?.location?.coordinate
          else {
            completion(nil)
            return
          }
          let coordinate = Coordinate(
            latitude: clCoordinate.latitude,
            longitude: clCoordinate.longitude)
          completion(coordinate)
        }
      }
    

现在,Xcode 抱怨 @escaping 关键字在 APIClientProtocol 协议中的 completion 参数。

  1. APIClientProtocol 定义替换为以下内容:

    // APIClient.swift
    protocol APIClientProtocol {
      func coordinate(
        for: String,
        completion: @escaping (Coordinate?) -> Void)
    }
    
  2. 运行所有测试以确认现在所有测试都通过。

使用此实现,我们的应用现在可以获取地址字符串的坐标。此功能使用户能够将位置添加到待办事项中。

在下一节中,我们将实现从服务器获取待办事项。我们不需要实际的服务器来编写测试和实现此功能。这是测试驱动开发的优势之一。

测试与 URLSession 通信的 async/await 代码

2021 年,苹果在 Swift 中引入了 async/await。有了 async/await,异步代码(例如,从服务器获取信息)更容易编写和理解。在本节中,我们将学习如何使用 URLSession 类的 async/await API 实现从网络服务器获取数据;当然,我们将使用测试驱动开发来完成这项工作。

单元测试需要快速且可重复。这意味着我们不想在我们的单元测试中依赖于与真实服务器的连接。相反,我们将用模拟对象替换与服务器的通信。

按照以下步骤实现从服务器获取待办事项:

  1. 在测试中,我们将使用 URLSession 类的模拟对象而不是真实的 URLSession 实例。为了能够用模拟对象替换真实的 URLSession 实例,我们需要一个定义我们想要替换的接口的协议。

  2. APIClient.swift 添加以下协议定义:

    // APIClient.swift
    protocol URLSessionProtocol {
      func data(for request: URLRequest,
        delegate: URLSessionTaskDelegate?)
      async throws -> (Data, URLResponse)
    }
    
  3. 接下来,我们需要告诉编译器 URLSession 类已经符合此协议。将以下代码添加到 APIClient.swift

    // APIClient.swift
    extension URLSession: URLSessionProtocol {}
    
  4. 选择 URLSessionProtocolMock。将其内容替换为以下内容:

    // URLSessionProtoclMock.swift
    import Foundation
    @testable import ToDo
    
    class URLSessionProtocolMock: URLSessionProtocol { 
      var dataForDelegateReturnValue: (Data, URLResponse)?
      var dataForDelegateRequest: URLRequest?
    
      func data(for request: URLRequest,
        delegate: URLSessionTaskDelegate?)
      async throws -> (Data, URLResponse) {
    
        dataForDelegateRequest = request
    
        guard let dataForDelegateReturnValue =
          dataForDelegateReturnValue else {
            fatalError()
            }
        return dataForDelegateReturnValue
      }
    }
    

这个模拟对象允许我们在将要编写的测试中定义 data(for:delegate:) 的返回值。

  1. 准备就绪后,我们可以开始编写测试。将以下测试方法的片段添加到APIClientTests

    // APIClientTests.swift
    func test_toDoItems_shouldFetcheItems() async throws {
      let url = try XCTUnwrap
        (URL(string: "http://toodoo.app/items"))
      let urlSessionMock = URLSessionProtocolMock()
      let expected = [ToDoItem(title: "dummy title")]
      urlSessionMock.dataForDelegateReturnValue = (
        try JSONEncoder().encode(expected),
        HTTPURLResponse(url: url,
          statusCode: 200,
          httpVersion: "HTTP/1.1",
          headerFields: nil)!
      )
      sut.session = urlSessionMock
    }
    

在此代码中,我们定义了 URL 和用于模拟响应的数据。urlSessionMock类返回一个包含一个ToDoItem对象和一个带有预期 URL 和状态码200HTTPURLResponse实例的 JSON 对象。

我们必须暂停编写测试,因为系统测试(APIClient类)还没有session属性。

  1. 前往APIClient并添加如下属性:

    // APIClient.swift
    lazy var session: URLSessionProtocol
    = URLSession.shared
    
  2. 切换回测试类并添加获取待办事项的调用:

    // APIClientTests.swift
    func test_toDoItems_shouldFetcheItems() async throws {
      let url = try XCTUnwrap
        (URL(string: "http://toodoo.app/items"))
      let urlSessionMock = URLSessionProtocolMock()
      let expected = [ToDoItem(title: "dummy title")]
      urlSessionMock.dataForDelegateReturnValue = (
        try JSONEncoder().encode(expected),
        HTTPURLResponse(url: url,
          statusCode: 200,
          httpVersion: "HTTP/1.1",
          headerFields: nil)!
      )
      sut.session = urlSessionMock
      let items = try await sut.toDoItems()
    }
    

再次,我们必须暂停,因为这个方法尚未定义。

  1. 前往APIClient并添加最小实现以使测试编译:

    // APIClient.swift
    func toDoItems() async throws -> [ToDoItem] {
      return []
    }
    
  2. 最后,我们可以完成测试。添加如代码片段所示的断言调用:

    // APIClientTests.swift
    func test_toDoItems_shouldFetcheItems() async throws {
      let url = try XCTUnwrap
        (URL(string: "http://toodoo.app/items"))
      let urlSessionMock = URLSessionProtocolMock()
      let expected = [ToDoItem(title: "dummy title")]
      urlSessionMock.dataForDelegateReturnValue = (
        try JSONEncoder().encode(expected),
        HTTPURLResponse(url: url,
          statusCode: 200,
          httpVersion: "HTTP/1.1",
          headerFields: nil)!
      )
      sut.session = urlSessionMock
      let items = try await sut.toDoItems()
      XCTAssertEqual(items, expected)
    }
    

运行测试以确认这个新测试失败。

  1. 为了使测试通过,将APIClient类中的toDoItems方法替换为以下代码:

    // APIClient.swift
    func toDoItems() async throws -> [ToDoItem] {
      guard let url =
        URL(string: "dummy")
      else {
        return []
      }
      let request = URLRequest(url: url)
      let (data, _) = try await session.data(
        for: request,
          delegate: nil)
      let items = try JSONDecoder()
        .decode([ToDoItem].self, from: data)
      return items
    }
    

在此代码中,我们定义了 URL,创建了一个请求,在会话属性上调用data(for:delegate:),并尝试将结果解码为ToDoItems数组。

运行测试以确认这段代码使测试通过。

但是,这段代码中有些奇怪。URL 是错误的。我们需要扩展测试以检查使用的 URL。

  1. 将以下断言函数调用添加到test_doToItems_shouldFetchesItems的末尾:

    // APIClientTests.swift
    XCTAssertEqual(urlSessionMock.dataForDelegateRequest,
      URLRequest(url: url))
    

运行测试以确认测试现在失败,因为我们使用了错误的 URL。

  1. 为了使测试通过,将toDoItems方法中的 URL 初始化替换为以下实现:

    // APIClient.swift
    guard let url =
      URL(string: "http://toodoo.app/items")
    else {
      return []
    }
    

运行测试以确认现在所有测试都通过。

当然,这个实现只是一个示例,帮助你开始测试网络调用。在实际应用中,你会在网络调用中添加授权,以确保用户只能访问他们的待办事项,而不能访问其他用户的待办事项。

从网络服务获取数据可能会出错。在以下部分,我们将测试URLSession实例的错误是否传递给了toDoItems的调用者。

错误处理

为了测试对网络服务URLSession调用的错误处理,我们首先需要增强URLSessionProtocolMock。按照以下步骤测试从APIClient实例调用中传递下来的数据获取错误:

  1. 将以下属性添加到URLSessionProtocolMock

    // URLSessionProtocolMock.swift
    var dataForDelegateError: Error?
    
  2. 接下来,将以下错误处理添加到data(for:delegate:)的开始部分:

    // URLSessionProtocolMock.swift
    if let error = dataForDelegateError {
      throw error
    }
    

如果将错误设置到dataForDelegateError属性中,我们会在执行此方法中的其他任何操作之前抛出它。

  1. 现在,我们准备好将测试方法添加到APIClientTests

    // APIClientTests.swift
    func test_toDoItems_whenError_shouldPassError() async
     throws {
      let urlSessionMock = URLSessionProtocolMock()
      let expected = NSError(domain: "", code: 1234)
      urlSessionMock.dataForDelegateError = expected
      sut.session = urlSessionMock
      do {
        _ = try await sut.toDoItems()
        XCTFail()
      } catch {
        let nsError = try XCTUnwrap(error as NSError)
        XCTAssertEqual(nsError, expected)
      }
    }
    

在这段代码中,我们创建了一个错误并将其分配给urlSessionMockdataForDelegateError属性。然后,我们在do-catch块中调用sut.toDoItems()以捕获我们期望从调用中得到的错误。如果没有从toDoItems()抛出错误,测试将因XCTFail而失败。否则,我们比较错误与期望的值。

再次运行测试。所有测试已经通过。这是不好的。正如你已经学到的,在测试驱动开发中,在我们将其变为绿色之前,我们需要看到测试失败。否则,我们无法确定测试是否可以失败。编写总是通过测试很容易。所以,让我们让这个测试失败。

  1. URLSessionProtocolMock中的data(for:delegate:)的实现更改,使其看起来像这样:

    // URLSessionProtocolMock.swift
    func data(for request: URLRequest,
      delegate: URLSessionTaskDelegate?)
    async throws -> (Data, URLResponse) {
      throw NSError(domain: "dummy", code: 0)
      if let error = dataForDelegateError {
        throw error
      }
      dataForDelegateRequest = request
      guard let dataForDelegateReturnValue =
              dataForDelegateReturnValue else {
                fatalError()
              }
      return dataForDelegateReturnValue
    }
    

再次运行测试以确认这个更改导致新的测试失败。

  1. 再次删除throw NSError(domain: "dummy", code: 0)行并运行测试以查看所有测试都通过。

通过这个测试,我们确认了从网络服务获取数据时出现的错误会被传递给toDoItems()的调用者。

但是,当网络服务的数据不是我们期望的格式时会发生什么?在这种情况下应该发生什么?按照以下步骤添加对这个情况的测试:

  1. 将以下测试方法添加到APIClientTests中:

    // APIClientTests.swift
    func
     test_toDoItems_whenJSONIsWrong_shouldFetcheItems()
     async throws {
      let url = try XCTUnwrap(URL(string: "foo"))
      let urlSessionMock = URLSessionProtocolMock()
      urlSessionMock.dataForDelegateReturnValue = (
        try JSONEncoder().encode("dummy"),
        HTTPURLResponse(url: url,
          statusCode: 200,
          httpVersion: "HTTP/1.1",
          headerFields: nil)!
      )
      sut.session = urlSessionMock
      do {
        _ = try await sut.toDoItems()
        XCTFail()
      } catch {
        XCTAssertTrue(error is Swift.DecodingError)
      }
    }
    

当调用urlSessionMocktoDoItems时,我们返回的数据是dummy字符串的 JSON 对象。尝试将其解码为ToDoItem对象数组应导致Swift.DecodingError类型的错误。这是测试中的最后一个断言所断言的。

  1. 再次运行测试。再次,所有测试都通过了。但是,我们还需要更改一些东西才能看到这个测试失败。

  2. 前往APIClient并将toDoItems()替换为以下实现:

    // APIClient.swift
    func toDoItems() async throws -> [ToDoItem] {
      guard let url =
        URL(string: "http://toodoo.app/items")
      else {
        return []
      }
      let request = URLRequest(url: url)
      let (data, _) = try await session.data(
        for: request,
          delegate: nil)
      let items = try? JSONDecoder()
        .decode([ToDoItem].self, from: data)
      return items ?? []
    }
    

在这段代码中,我们更改了那个方法中的最后三行。当我们尝试从网络服务解码数据时,我们使用try?而不是try。当数据无法解码为ToDoItem数组时,结果是可选的,不会抛出错误。因此,我们还需要更改return值。当items属性的值为nil时,我们返回一个空数组。

运行测试。我们最近添加的测试现在失败了,并且我们已经确认它确实可能会失败。

  1. toDoItems()的实现更改为之前的版本:

    // APIClient.swift
    func toDoItems() async throws -> [ToDoItem] {
      guard let url =
        URL(string: "http://toodoo.app/items")
      else {
        return []
      }
      let request = URLRequest(url: url)
      let (data, _) = try await session.data(
        for: request,
          delegate: nil)
      let items = try JSONDecoder()
        .decode([ToDoItem].self, from: data)
      return items
    }
    

再次运行测试以查看所有测试都通过。

对于这个网络服务调用的实现,还有许多更多的测试需要编写。例如,你应该也为网络服务以除200以外的 HTTP 状态码响应的情况编写测试。这些测试留给你作为练习。添加对这个 API 调用的测试,直到你确信这个功能在未来不会在未被发现的情况下破坏。

摘要

本章中,我们学习了如何编写对CLGeoCoder的调用测试,以及如何测试URLSession的异步/等待 REST API 调用。我们看到了在测试方法中需要做什么来测试异步/等待调用中是否抛出错误。此外,我们还学习了如何使我们的网络代码测试与服务器基础设施的实现独立。这样我们使测试变得快速且健壮。

您可以使用本章学到的技能来编写您应用完整网络层的测试。但您不必止步于此。本章中我们讨论的策略同样有助于编写各种异步/等待(async/await)API 的测试。

在下一章中,我们将把到目前为止编写的所有代码整合在一起,并最终在模拟器上运行应用程序。

练习

  1. 我们使用x_前缀禁用了一个测试方法,以将其从测试运行器中隐藏。还有其他方法可以禁用单个测试。在网上做一些研究,找出这些方法。

  2. 本章中,我们为URLSession的异步/等待 API 编写了测试。但是,URLSession还提供了一个使用代理模式(delegate pattern)的 API 和一个使用 Combine 的 API。在网上做一些研究,找出如何为这些 API 编写单元测试。确保这些测试在没有连接到服务器的情况下也能运行。

第十一章:第十一章:使用协调器轻松导航

一个 iOS 应用通常是一组以某种方式相互连接的单个屏幕。不经验丰富的开发者通常会从一个视图控制器中展示一个视图控制器,因为这很容易实现,并且在教程和演示代码中通常也是这样展示的。但是,对于需要长期维护的应用,我们需要一个更容易理解和更改的模式。

协调器模式非常容易实现,同时还能将应用视图之间的导航与信息展示解耦。在协调器模式中,一个称为协调器的结构负责在视图之间导航。视图控制器告诉协调器用户与应用进行了交互,协调器决定哪个视图控制器应该负责下一个屏幕。

作为额外的好处,协调器模式使测试导航代码更加简单和健壮,因此,这种模式非常适合测试驱动开发TDD)。

在本书中构建的应用是一个只有三个屏幕的小应用。这三个屏幕之间的导航可以捆绑到一个协调器中。在更复杂的应用中,你通常会使用多个协调器。要了解更多关于协调器模式的信息,互联网上有大量关于该主题的博客文章。你不需要了解任何关于该模式的知识,就可以跟随本章中的代码。

在本章中,你将学习如何使用协调器模式测试和实现应用不同视图之间的导航。

本章的结构如下:

  • 测试应用的设置

  • 导航到详细信息

  • 导航到模态视图

  • 添加缺失的部分

让我们从使用协调器模式重构应用设置开始。

技术要求

本章的源代码可以在以下位置找到:github.com/PacktPublishing/Test-Driven-iOS-Development-with-Swift-Fourth-Edition/tree/main/chapter11

测试应用的设置

当我们的应用启动时,应该实例化并启动一个协调器。这应该导致我们应用初始视图的展示。按照以下步骤从使用故事板重构设置到使用协调器:

  1. 在我们可以重构应用设置之前,我们需要一个测试来告诉我们何时破坏了某些东西。选择AppSetupTests

  2. 将新类的内容替换为以下内容:

    // AppSetupTests.swift
    import XCTest
    @testable import ToDo
    class AppSetupTests: XCTestCase {
      func test_application_shouldSetupRoot() {
        let application = UIApplication.shared
        let scene = application.connectedScenes.first
        as? UIWindowScene
        let root =
          scene?.windows.first?.rootViewController
        XCTAssert(root is ToDoItemsListViewController)
      }
    }
    

在这个测试中,我们获取我们应用第一个窗口的rootViewController属性,并检查它是否为ToDoItemsListViewController类型。

  1. 运行测试以确认所有测试现在都通过。这个测试通过是因为故事板被设置为应用以ToDoItemsListViewController类的实例启动。

  2. 前往Main.storyboard,并在属性检查器中取消勾选ToDoItemsListViewController场景的复选框。

图 11.1 – 从故事板中移除初始视图控制器设置

图 11.1 – Removing the initial view controller setting from the storyboard

图 11.1 – 从故事板中移除初始视图控制器设置

  1. 切换到ToDoItemsListViewController。通过这个更改,我们可以在代码中使用此 ID 实例化这个视图控制器。

  2. 运行测试以确认现在我们添加的最后一个测试失败了。哦,还有一个测试也失败了。在ToDoItemsListViewControllerTests中,所有测试都失败了,因为在setUpWithError中的设置抛出了一个错误。在我们继续设置应用之前,让我们先修复这个错误。ToDoItemsListViewController不再是故事板中的初始视图控制器。这意味着我们需要使用其 ID 来加载它。将setUpWithError()替换为以下实现:

    // ToDoItemsListViewControllerTests.swift
    override func setUpWithError() throws {
      let storyboard = UIStoryboard(name: "Main", bundle:
        nil)
      sut = try XCTUnwrap(
        storyboard.instantiateViewController(
          withIdentifier: "ToDoItemsListViewController")
        as? ToDoItemsListViewController
      )
      toDoItemStoreMock = ToDoItemStoreProtocolMock()
      sut.toDoItemStore = toDoItemStoreMock
      sut.loadViewIfNeeded()
    }
    
  3. 再次运行所有测试。现在,只有我们的应用设置测试失败了。很好。让我们继续到实现部分。

  4. 选择AppCoordinator.swift。用以下代码替换新文件的内容:

    // AppCoordinator.swift
    import UIKit
    
    protocol Coordinator {
      func start()
    }
    

这段代码定义了Coordinator协议。这就是我实现协调器模式的方式。在其他开发者的博客和书籍中,你可能会找到其他实现。不用担心,它们只是在细节上有所不同。当你对协调器模式有了一些了解后,你可能会开发出自己的实现。

这个协议的实现告诉我们,协调器有一个start方法。

  1. 在同一文件中添加我们AppCoordinator的以下实现:

    // AppCoordinator.swift
    class AppCoordinator: Coordinator {
      private let window: UIWindow?
      private let viewController: UIViewController
      init(window: UIWindow?) {
        self.window = window
        let storyboard = UIStoryboard(name: "Main",
          bundle: nil)
        viewController =
          storyboard.instantiateViewController(
          withIdentifier: "ToDoItemsListViewController")
      }
      func start() {
        window?.rootViewController = viewController
      }
    }
    

AppCoordinator的初始化器中,我们存储了作为参数传递的窗口,并设置了初始视图控制器的视图。在start方法中,我们设置了窗口的rootViewController属性。

  1. 前往SceneDelegate并添加以下属性:

    // SceneDelegate.swift
    var appCoordinator: AppCoordinator?
    
  2. 接下来,将scene(_:willConnectTo:options:)方法替换为以下代码:

    // SceneDelegate.swift
    func scene(_ scene: UIScene,
      willConnectTo session: UISceneSession,
      options connectionOptions:
      UIScene.ConnectionOptions) {
      guard let scene = (scene as? UIWindowScene) else {
        return
      }
      window = UIWindow(windowScene: scene)
      let coordinator = AppCoordinator(
        window: window)
      coordinator.start()
      appCoordinator = coordinator
      window?.makeKeyAndVisible()
    }
    

在这段代码中,我们首先设置了一个UIWindow类的实例。接下来,我们实例化了AppCoordinator实例并调用了它的start方法。最后,我们在窗口上调用makeKeyAndVisible来告诉UIKit这个窗口应该显示在屏幕上。

  1. 运行所有测试以确认我们的重构是成功的。

现在,应用的工作方式与之前相同。当应用启动时,会创建并显示一个ToDoItemsListViewController实例。但是,这并不是最终应用应该工作的方式。待办事项列表需要在UINavigationController实例上显示,以便以后能够导航到待办事项的详细信息。

按照以下步骤进行此更改:

  1. test_application_shouldSetupRoot()测试方法替换为以下实现:

    // AppSetupTests.swift
    func test_application_shouldSetupRoot() throws {
      let application = UIApplication.shared
      let scene = application.connectedScenes.first
      as? UIWindowScene
      let root = scene?.windows.first?.rootViewController
      let nav = try XCTUnwrap(root as?
        UINavigationController)
      XCTAssert(nav.topViewController
        is ToDoItemsListViewController)
    }
    
  2. 运行测试以查看现在在XCTAssert调用之前的行中这个测试失败了。

  3. 为了让这个测试再次通过,我们首先需要在AppCoordinator类中为导航控制器添加一个属性:

    // AppCoordinator.swift
    private let navigationController:
      UINavigationController
    
  4. 接下来,我们在init方法中设置导航控制器:

    // AppCoordinator.swift
    init(window: UIWindow?,
      navigationController: UINavigationController =
      UINavigationController()) {
      self.window = window
      self.navigationController = navigationController
      let storyboard = UIStoryboard(name: "Main", bundle:
        nil)
      viewController =
        storyboard.instantiateViewController(
        withIdentifier: "ToDoItemsListViewController")
    }
    

我们已将导航控制器作为具有默认值的参数添加到init调用中。这将在我们添加导航到待办事项详细信息的测试时派上用场。

  1. 现在,我们可以将ToDoItemsListViewController的实例添加到导航控制器的导航堆栈中,以更改start方法:

    // AppCoordinator.swift
    func start() {
      navigationController.pushViewController
       (viewController,
        animated: false)
      window?.rootViewController = navigationController
    }
    

运行测试以确认所有测试现在都再次通过。

我们还没有完成设置列表视图控制器。记住,列表视图控制器通过用户与遵循我们在第七章中定义的ToDoItemsListViewControllerProtocol协议的代理对象进行交互,构建待办事项的表视图控制器。按照以下步骤实现这一部分的设置:

  1. 选择AppCoordinatorTests。删除两个模板测试方法,并添加@testable import ToDo导入语句。

  2. 现在,我们将使AppCoordinator类遵循ToDoItemsListViewControllerProtocol协议。将以下代码添加到AppCoordinator.swift中:

    // AppCoordinator.swift 
    extension AppCoordinator:  
      ToDoItemsListViewControllerProtocol { 
    
      func selectToDoItem(_ viewController:
        UIViewController, 
        item: ToDoItem) { 
      } 
    }  
    

此实现目前什么也不做。我们将在下一节中实现此方法。

  1. 要为分配列表视图控制器代理属性编写测试,我们需要在test方法中访问列表视图控制器。AppCoordinator类的视图控制器属性是私有的。这意味着我们无法在测试中访问它。我们可以更改视图控制器属性的访问级别。

但出于教育目的,我们将做些其他事情。我们将一个导航控制器模拟传递给AppCoordinator类的init方法,并从该模拟中获取初始视图控制器。

  1. NavigationControllerMock添加一个新的 Swift 类。用以下代码替换新文件的内容:

    // NavigationControllerMock.swift 
    import UIKit 
    
    class NavigationControllerMock: UINavigationController { 
      var lastPushedViewController: UIViewController? 
    
      override func pushViewController( 
        _ viewController: UIViewController, 
        animated: Bool) {   
          lastPushedViewController = viewController 
          super.pushViewController(viewController, 
            animated: animated) 
        } 
    } 
    

这个UINavigationController类的子类存储了最后推入的视图控制器以供后续检查,然后调用super类的实现。

  1. 现在,我们可以在AppCoordinator测试中使用这个类。将以下属性添加到AppCoordinatorTests中:

    // AppCoordinatorTests.swift 
    var sut: AppCoordinator! 
    var navigationControllerMock: 
    NavigationControllerMock! 
    var window: UIWindow!
    
  2. setUpWithError方法替换为以下代码:

    // AppCoordinatorTests.swift 
    override func setUpWithError() throws { 
      window = UIWindow(frame: CGRect(x: 0, 
        y: 0, 
        width: 200, 
        height: 200)) 
      navigationControllerMock =
        NavigationControllerMock() 
      sut = AppCoordinator( 
        window: window, 
        navigationController: navigationControllerMock) 
    } 
    

在此代码中,我们创建了一个模拟窗口和一个NavigationControllerMock实例,并使用这两个实例初始化一个AppCoordinator实例。

  1. 对于测试所设置的内容,测试完成后必须清理。将tearDownWithError方法替换为以下代码:

    // AppCoordinatorTests.swift 
    override func tearDownWithError() throws { 
      sut = nil 
      navigationControllerMock = nil 
      window = nil 
    }
    
  2. 准备就绪后,我们可以添加一个测试来确认start方法将AppCoordinator的实例分配给列表视图控制器的代理:

    // AppCoordinatorTests.swift
    func test_start_shouldSetDelegate() throws {
      sut.start()
      let listViewController = try XCTUnwrap(
        navigationControllerMock.lastPushedViewController
        as? ToDoItemsListViewController)
      XCTAssertIdentical(
        listViewController.delegate as? AppCoordinator,
        sut)
    }
    

在这个测试中,我们调用AppCoordinator实例的start方法,然后断言sut被分配到列表视图控制器的代理属性。我们在这里使用XCTAssertIdentical(_:_:)断言函数。由于AppCoordinator是一个类,我们可以在测试中检查代理是否与sut相同。这个断言函数比较两个项目的指针地址,当两个引用相同时测试通过。这不适用于值类型,因为它们在分配(或更确切地说,在更改时)时被复制。

运行测试以确认这个新测试失败。

  1. 要使这个测试通过,请将以下代码添加到AppCoordinator中的start方法末尾:

    // AppCoordinator.swift
    if let listViewController =
      viewController as? ToDoItemsListViewController {
      listViewController.delegate = self
    }
    

运行测试以确认这个添加使测试通过。

ToDoItemsListViewController显示从ToDoItemStore实例获取的待办事项。我们需要在设置时向列表视图控制器提供一个项目存储。按照以下步骤将项目存储添加到列表视图控制器:

  1. 将以下测试添加到AppCoordinatorTests

    // AppCoordinatorTests.swift
    func test_start_shouldAssignItemStore() throws {
      sut.start()
      let listViewController = try XCTUnwrap(
        navigationControllerMock.lastPushedViewController
        as? ToDoItemsListViewController)
      XCTAssertNotNil(listViewController.toDoItemStore)
    }
    

在这个测试中,我们断言列表视图控制器的toDoItemStore属性不为空。运行测试以确认这个测试失败。

  1. 让这个测试通过。将以下属性添加到AppCoordinator

    // AppCoordinator.swift
    let toDoItemStore: ToDoItemStore
    
  2. init方法中分配这个属性一个新的实例:

    // AppCoordinator.swift
    toDoItemStore = ToDoItemStore()
    
  3. 现在,在start方法的if let语句中将这个属性分配给列表视图控制器的属性:

    // AppCoordinator.swift
    func start() {
      navigationController.pushViewController
        (viewController,
         animated: false)
      window?.rootViewController = navigationController
      if let listViewController =
          viewController as? ToDoItemsListViewController {
        listViewController.delegate = self
        listViewController.toDoItemStore = toDoItemStore
      }
    }
    

运行测试以确认所有测试现在通过。

协调器和初始视图控制器的设置现在已完成。我们可以继续实现列表视图控制器与应用程序协调器的交互。

当用户点击带有待办事项的表格视图单元格时,应用应该导航到该事项的详细信息。在以下部分,我们将实现这个功能。

导航到详细信息

我们将在应用中使用AppCoordinator类实现导航。按照以下步骤实现导航到待办事项的详细信息:

  1. 将以下测试方法添加到AppCoordinatorTests

    // AppCoordinatorTests.swift
    func test_selectToDoItem_pushesDetails() throws {
      let dummyViewController = UIViewController()
      let item = ToDoItem(title: "dummy title")
      sut.selectToDoItem(dummyViewController, item: item)
      let detail = try XCTUnwrap(
        navigationControllerMock.lastPushedViewController
        as? ToDoItemDetailsViewController)
      XCTAssertEqual(detail.toDoItem, item)
    }
    

在这个测试中,我们执行delegate方法并断言ToDoItemDetailsViewController的实例被推送到导航堆栈中,并且它的toDoItem是我们用于delegate方法调用中的项目。

运行测试以确认这个新测试失败。

  1. 用以下实现替换selectToDoItem(_:item:)的实现:

    // AppCoordinator.swift 
    func selectToDoItem(_ viewController:
      UIViewController, 
      item: ToDoItem) { 
    
      let storyboard = UIStoryboard(name: "Main", bundle:
        nil) 
      guard let next =
        storyboard.instantiateViewController( 
        withIdentifier: "ToDoItemDetailsViewController") 
          as? ToDoItemDetailsViewController else { 
                return 
              } 
    
      next.loadViewIfNeeded() 
      next.toDoItem = item 
    
      navigationController.pushViewController(next, 
        animated: true) 
    } 
    

在此代码中,我们从 Storyboard 中实例化一个ToDoItemDetailsViewController实例,并使用传递给方法的项目进行设置。然后我们将新的视图控制器推送到导航堆栈。

运行测试以确认所有测试现在再次通过。

  1. 详细视图控制器需要一个对toDoItemStore的引用,因为用户可以在详细视图中更改事项的状态为完成。将以下测试添加到AppCoordinatorTests

    // AppCoordinatorTests.swift
    func test_selectToDoItem_shouldSetItemStore() throws {
      let dummyViewController = UIViewController()
      let item = ToDoItem(title: "dummy title")
      sut.selectToDoItem(dummyViewController, item: item)
      let detail = try XCTUnwrap(
        navigationControllerMock.lastPushedViewController
        as? ToDoItemDetailsViewController)
      XCTAssertIdentical(
        detail.toDoItemStore as? ToDoItemStore,
        sut.toDoItemStore)
    }
    

这个测试看起来和上一个一样。我们只是将断言函数调用更改为检查 toDoItemStore 属性是否与 sut 属性相同。

运行测试以查看这个测试失败。

  1. 为了使这个测试通过,在分配 toDoItem 属性的下一行分配 toDoItemStore 属性:

    // AppCoordinator.swift
    next.loadViewIfNeeded()
    next.toDoItem = item
    next.toDoItemStore = toDoItemStore
    

运行测试以确认所有测试通过。

当用户选择一个带有待办事项的单元格时,我们的应用现在会在屏幕上显示该事项的详细信息。还有一个功能缺失。应用需要允许输入新的待办事项。我们将在下一节中实现从列表视图到输入视图的展示。

导航到模态视图

通常,测试模态视图控制器的展示相当复杂。如果你在网上搜索如何做,你会找到常见的解决方案是通过重写 UIViewController 类中定义的 present(_:animated:completion:) 方法。重写相当复杂,我不会在这本书中展示如何进行。

但是,因为我们正在使用协调器模式进行我们的应用导航,所以我们可以测试展示,而无需对任何方法进行重写。尽管如此,你应该查找如何重写方法,因为有时你无法使用协调器模式;例如,当已经实现了所有导航代码,并且不允许你更改它时。

按照以下步骤实现当用户选择添加新待办事项时输入视图的展示:

  1. 应用需要在用户界面中添加一个按钮,用户可以点击以添加待办事项。当用户点击该按钮时,列表视图控制器应该通知其代理。向 ToDoItemsListViewControllerProtocol 添加以下方法定义:

    // ToDoItemsListViewController.swift
    func addToDoItem(
      _ viewController: UIViewController)
    
  2. 为了让编译器满意,向 AppCoordinator 添加以下空方法实现:

    // AppCoordinator.swift
    func addToDoItem(_ viewController: UIViewController) {
    }
    
  3. 我们还有一个符合 ToDoItemsListViewControllerProtocol 协议的类。将以下代码添加到 ToDoItemsListViewControllerProtocolMock 类的末尾:

    // ToDoItemsListViewControllerProtocolMock.swift
    var addToDoItemCallCount = 0
    func addToDoItem(_ viewController: UIViewController) {
      addToDoItemCallCount += 1
    }
    

模拟对象计算 addToDoItem(_:) 方法的调用次数。

  1. 接下来,我们需要一个视图控制器模拟,它可以捕获最后展示的视图控制器。选择 ViewControllerMock.swift 文件。用以下代码替换其内容:

    // ViewControllerMock.swift
    import UIKit
    class ViewControllerMock: UIViewController {
      var lastPresented: UIViewController?
      override func present(
        _ viewControllerToPresent: UIViewController,
        animated flag: Bool,
        completion: (() -> Void)? = nil) {
        lastPresented = viewControllerToPresent
        super.present(viewControllerToPresent,
          animated: flag,
          completion: completion)
      }
    }
    

这个模拟将展示的视图控制器存储在一个属性中,以便稍后检查。

  1. 现在,我们可以编写测试了。将 SwiftUI 导入到 AppCoordinatorTests.swift 文件中,并在 AppCoordinatorTests 中添加以下测试方法:

    // AppCoordinatorTests.swift
    func test_addToDoItem_shouldPresentInputView() throws
     {
      let viewControllerMock = ViewControllerMock()
      sut.addToDoItem(viewControllerMock)
      let lastPresented = try XCTUnwrap(
        viewControllerMock.lastPresented
        as? UIHostingController<ToDoItemInputView>)
      XCTAssertIdentical(
        lastPresented.rootView.delegate as?
        AppCoordinator,
        sut)
    }
    

这个测试调用 addToDoItem(_:) 并断言 sut 变量被分配为展示的 ToDoItemInputView 实例的代理。

运行测试以确认这个新测试失败。

  1. 为了使这个测试通过,将 SwiftUI 导入到 AppCoordinator.swift 文件中,并用以下代码替换 addToDoItem(_:) 的实现:

    // AppCoordinator.swift
    func addToDoItem(_ viewController: UIViewController) {
      let data = ToDoItemData()
      let next = UIHostingController(
        rootView: ToDoItemInputView(data: data,
          apiClient: APIClient(),
          delegate: self))
      viewController.present(next, animated: true)
    }
    

Xcode 显示了一个错误;我们将在下一步修复此错误。此代码使用ToDoItemInputView作为根视图实例化一个UIHostingController。这就是我们从UIKit环境呈现SwiftUI视图的方法。

  1. 为了使此代码编译,请将以下扩展添加到AppCoordinator.swift

    // AppCoordinator.swift
    extension AppCoordinator: ToDoItemInputViewDelegate {
      func addToDoItem(with: ToDoItemData,
        coordinate: Coordinate?) {
      }
    }
    

运行测试以确认所有测试现在都通过。

此功能的其中一部分已完成。接下来,我们需要在ToDoItemsListViewController类中实现其他部分。

  1. 将以下测试方法添加到ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    func test_navigationBarButton_shouldCallDelegate()
     throws {
      let delegateMock =
      ToDoItemsListViewControllerProtocolMock()
      sut.delegate = delegateMock
      let addButton =
        sut.navigationItem.rightBarButtonItem
      let target = try XCTUnwrap(addButton?.target)
      let action = try XCTUnwrap(addButton?.action)
      _ = target.perform(action, with: addButton)
      XCTAssertEqual(delegateMock.addToDoItemCallCount, 1)
    }
    

在此测试中,我们获取sut变量的正确工具栏按钮项并调用其目标上的操作。这应该导致调用代理的addToDoItem(_:)方法。

运行测试并确认此新测试失败。

  1. 将以下代码添加到ToDoItemsListViewControllerviewDidLoad()末尾:

    // ToDoItemsListViewController.swift
    let addItem = UIBarButtonItem(barButtonSystemItem:
      .add,
      target: self,
      action: #selector(add(_:)))
    navigationItem.rightBarButtonItem = addItem
    

使用此代码,我们在ToDoItemsListViewController实例的导航项中添加了一个工具栏按钮。这导致添加到托管ToDoItemsListViewController的导航控制器导航栏中的工具栏按钮。

  1. 现在,将以下方法添加到ToDoItemsListViewController

    // ToDoItemsListViewController.swift
    @objc func add(_ sender: UIBarButtonItem) {
    }
    

目前,我们让此方法的实现为空,因为我们想看到在断言函数调用中测试失败。运行测试并确认我们最后添加的测试在断言调用中失败。

  1. 为了使测试通过,请在add(_:)中添加缺失的代码:

    // ToDoItemsListViewController.swift
    @objc func add(_ sender: UIBarButtonItem) {
      delegate?.addToDoItem(self)
    } 
    

运行测试以确认所有测试现在都通过。

我们已经知道ToDoItemInputView在用户选择ToDoItemStoreadd(_:)方法时会调用其代理。采取以下步骤来实现此功能。

  1. 将以下测试添加到AppCoordinatorTests

    // AppCoordinatorTests.swift
    func test_addToDoItemWith_shouldCallToDoItemStore()
     throws {
      let toDoItemData = ToDoItemData()
      toDoItemData.title = "dummy title"
      let receivedItems =
      try wait(for: sut.toDoItemStore.itemPublisher,
        afterChange: {
        sut.addToDoItem(with: toDoItemData, coordinate:
          nil)
      })
      XCTAssertEqual(receivedItems.first?.title, 
        toDoItemData.title)
    }
    

此测试断言,在调用addToDoItem(with:coordinate:)之后,现在toDoItemStore属性的itemPublisher发布了存储项的变化。

  1. 由于我们正在向项目存储添加待办事项,我们需要将AppCoordinator中的doToItemStore替换为测试存储。否则,由于其他测试或我们在模拟器上测试应用程序时添加到存储中的项,测试可能会失败。

  2. AppCoordinator类的init方法替换为以下实现:

    // AppCoordinator.swift
    init(window: UIWindow?,
     navigationController: UINavigationController =
         UINavigationController(),
         toDoItemStore: ToDoItemStore = ToDoItemStore()) {
      self.window = window
      self.navigationController = navigationController
      self.toDoItemStore = toDoItemStore
      let storyboard = UIStoryboard(name: "Main", bundle:
        nil)
      viewController = 
        storyboard.instantiateViewController(
        withIdentifier: "ToDoItemsListViewController")
    }
    

在这里,我们已将toDoItemStore参数添加到方法中,并使用该参数设置AppCoordinator类中使用的toDoItemStore属性。

  1. 因此,我们可以在setUpWithError中设置sut时使用测试存储。

    // AppCoordinatorTests.swift
    override func setUpWithError() throws {
      window = UIWindow(frame: CGRect(x: 0,
        y: 0,
        width: 200,
        height: 200))
      navigationControllerMock =
        NavigationControllerMock()
      sut = AppCoordinator(
        window: window,
        navigationController: navigationControllerMock,
        toDoItemStore: ToDoItemStore(fileName:
          "dummy_store"))
    }
    
  2. 要在测试完成后删除项目存储,请将以下代码添加到AppCoordinatorTeststearDownWithError末尾:

    // AppCoordinatorTests.swift
    let url = FileManager.default
      .documentsURL(name: "dummy_store")
    try? FileManager.default.removeItem(at: url)
    

此代码看起来很熟悉,因为我们已经在ToDoItemStoreTests中使用过它。

运行测试以确认新的测试失败。

  1. 使用以下addToDoItem(with:coordinate:)的实现使测试通过:

    // AppCoordinator.swift
    func addToDoItem(with item: ToDoItemData,
      coordinate: Coordinate?) {
      let location = Location(name: item.locationName,
        coordinate: coordinate)
      let toDoItem = ToDoItem(
        title: item.title,
        itemDescription: item.itemDescription,
        timestamp: item.date.timeIntervalSince1970,
        location: location)
      toDoItemStore.add(toDoItem)
    }
    

注意,我们为该方法的第一个参数添加了一个内部参数名item

在此代码中,我们从ToDoItemData结构创建了一个ToDoItem实例。然后,我们调用toDoItemStoreadd(_:)方法。

运行测试以确认这个更改使所有测试再次通过。

目前,我们已经完成了实现。让我们在模拟器中运行应用,看看我们是否遗漏了什么。

添加缺失的部分

首先,让我们运行这个应用第一次,看看我们现在在哪里。

应用从只有一个加号(+)按钮的空白屏幕开始,位于右上角。

Figure 11.2 – 我们应用的初始视图

Figure 11.02 – 表格视图的约束

图 11.2 – 我们应用的初始视图

因此,这里有一些工作要做。但是,让我们继续并点击加号(+)按钮。我们看到了输入视图。我们可以为项目添加数据并点击保存按钮。

Figure 11.3 – 我们应用的输入视图

Figure 11.03 – 表格视图的约束

图 11.3 – 我们应用的输入视图

但是,当我们点击保存按钮时,没有任何反应。通过向下滑动来关闭视图,看看项目是否已添加。有些东西改变了。屏幕中间出现了一个空白表格视图单元格。

Figure 11.4 – 一个空白表格视图单元格。待办事项在哪里?

Figure 11.04 – 表格视图的约束

图 11.4 – 一个空白表格视图单元格。待办事项在哪里?

当你点击空白表格视图单元格时,详细视图会被推送到屏幕上。

Figure 11.5 – 待办事项项的详细信息。但是,截止日期在哪里?

Figure 11.05 – 表格视图的约束

图 11.5 – 待办事项项的详细信息。但是,截止日期在哪里?

好的,我们有一些工作要做。让我们回到 Xcode 并修复一些问题。

使单元格可见

表格视图没有显示待办事项的信息。原因是,我们在添加标签时没有为视图添加约束。这是故意的,因为我相信你不应该编写单元测试来测试界面元素的定位和大小。UI 快照测试是这些类型测试的更好工具。

按照以下步骤修复单元格和表格视图的布局:

  1. 0中打开Main.storyboard并点击添加 4 个约束

Figure 11.6 – 表格视图的约束

Figure 11.06 – 表格视图的约束

图 11.6 – 表格视图的约束

  1. 接下来,转到ToDoItemCell,并用以下实现替换init方法:

    // ToDoItemCell.swift
    override init(style: UITableViewCell.CellStyle,
      reuseIdentifier: String?) {
      titleLabel = UILabel()
      dateLabel = UILabel()
      dateLabel.textAlignment = .right
      locationLabel = UILabel()
      let titleLocation = UIStackView(
        arrangedSubviews: [titleLabel, locationLabel])
      titleLocation.axis = .vertical
      let stackView = UIStackView(
        arrangedSubviews: [titleLocation, dateLabel])
      stackView
        .translatesAutoresizingMaskIntoConstraints = false
      super.init(style: style,
        reuseIdentifier: reuseIdentifier)
      contentView.addSubview(stackView)
      NSLayoutConstraint.activate([
       stackView.topAnchor.constraint(
          equalTo: contentView.topAnchor, constant: 5),
       stackView.leadingAnchor.constraint(
        equalTo: contentView.leadingAnchor, constant: 16),
       stackView.bottomAnchor.constraint(
        equalTo: contentView.bottomAnchor, constant: -5),
       stackView.trailingAnchor.constraint(
        equalTo: contentView.trailingAnchor, constant: -
        16),
      ])
    }
    

我们使用UIStackView实例来布局元素。运行测试以确认我们没有破坏任何东西。然后,在模拟器上再次运行应用。

它看起来更好,但表格视图单元格中仍然缺少截止日期。原因是我们没有设置当前的dateFormatter实例。我们找到了一个错误。每次我们找到错误时,我们都应该尝试编写一个因为该错误而失败的测试。然后,我们应该通过修复错误来使测试通过。

  1. 将以下测试方法添加到ToDoItemsListViewControllerTests

    // ToDoItemsListViewControllerTests.swift
    func test_dateFormatter_shouldNotBeNone() {
      XCTAssertNotEqual(sut.dateFormatter.dateStyle,
        .none)
    }
    

注意,XCTAssertNotEqual断言函数与XCTAssertEqual函数相反。当两个值不相等时,它通过。

运行测试以查看此测试失败。

  1. 要使此测试通过并消除错误,请将这些行添加到viewDidLoadsuper.viewDidLoad()行下方:

    // ToDoItemsListViewController.swift
    super.viewDidLoad()
    dateFormatter.dateStyle = .short
    
  2. 运行测试以确认这使测试通过。然后,在模拟器上运行应用。哇哦!我们用 TDD 帮助修复了第一个错误。这是一个里程碑。我们现在确信,只要定期运行这个测试,这个错误就不会再出现。

接下来,我们需要修复当用户点击保存按钮时,输入视图没有关闭的错误。

忽略输入视图

再次,我们有一个错误。让我们看看我们是否可以为这个错误编写一个测试。按照以下步骤修复错误:

  1. 导航控制器模拟应该注册dismiss(animated:completion:)是否被调用。这样,我们可以确保在添加新项目时调用它。将以下代码添加到NavigationControllerMock

    // NavigationControllerMock.swift
    var dismissCallCount = 0
    override func dismiss(animated flag: Bool,
      completion: (() -> Void)? = nil) {
      dismissCallCount += 1
      super.dismiss(animated: flag,
         completion: completion)
    }
    

此代码计算dismiss(animated:completion:)被调用的次数。

  1. 将以下测试方法添加到AppCoordinatorTests

    // AppCoordinatorTests.swift
    func test_addToDoItemWith_shouldDismissInput() {
      let toDoItemData = ToDoItemData()
      toDoItemData.title = "dummy title"
      sut.addToDoItem(with: toDoItemData,
        coordinate: nil)
      XCTAssertEqual(
        navigationControllerMock.dismissCallCount, 1)
    }
    

运行测试以查看此测试失败。

  1. 将以下代码添加到addToDoItem(with:coordinate:)的末尾:

    // AppCoordinatorTests.swift
    navigationController.dismiss(animated: true)
    

运行测试以确认此代码使所有测试再次通过。然后,运行应用并添加一个新的待办事项。

我们使用 TDD 修复了另一个错误。

接下来,让我们修复详情中未显示截止日期的错误。

在详情中使截止日期可见

日期未在详情中显示的原因与表格视图单元格相同。日期格式化器设置不正确。你已经知道如何编写这个测试。编写测试并确保测试失败。

要使测试通过并修复错误,你可以使用以下dateFormatter属性的definition

// ToDoItemDetailsViewController.swift
let dateFormatter: DateFormatter = {
  let formatter = DateFormatter()
  formatter.dateStyle = .short
  return formatter
}()

这应该会使你的测试通过。

再次运行应用,并与之互动。你可能会意识到更多错误。以下是我发现的内容:

  • 当用户在详情中点击完成按钮时,应用应该返回待办事项列表。

  • 表格视图中的部分标题缺失。已完成的项目已正确移动到第二个部分,但在用户界面中看不到有多个部分。

  • 当用户标记第一个项目为已完成时,表格视图中的顺序会改变。如果用户随后选择表格视图中的第一个项目,将显示其他项目的详情。

  • 即使用户从详情返回,单元格仍然保持选中状态。

  • 调试控制台显示一个警告,即当表格视图不可见时,被要求布局单元格。

我们确实发现了更多错误。

在以下部分,我们将只修复此列表中的第三个和第五个错误。其他错误留作你的练习。如果你卡住了,可以查看 GitHub 上本章的代码。

修复错误的项目被选中

再次,在我们尝试修复这个错误之前,让我们尝试编写一个测试。按照以下步骤修复这个错误:

  1. 问题在于我们在为可差分数据源创建快照时设置了部分,但在用户选择表格视图行时忽略了部分。我们可以将test_didSelectCellAt_shouldCallDelegate方法更改为检查这个错误。将那个测试方法的实现替换为以下代码:

    // ToDoItemsListViewControllerTests.swift
    func test_didSelectCellAt_shouldCallDelegate() throws
     {
      let delegateMock =
        ToDoItemsListViewControllerProtocolMock()
      sut.delegate = delegateMock
      var doneItem = ToDoItem(title: "done item")
      doneItem.done = true
      let toDoItem = ToDoItem(title: "to-do item")
      toDoItemStoreMock.itemPublisher
        .send([doneItem, toDoItem])
      let tableView = try XCTUnwrap(sut.tableView)
      let indexPath = IndexPath(row: 0, section: 0)
      tableView.delegate?.tableView?(
        tableView,
        didSelectRowAt: indexPath)
      XCTAssertEqual(
       delegateMock.selectToDoItemReceivedArguments?.item,
        toDoItem)
    }
    

我们将测试更改为使用两个项目,一个是已完成的项目,另一个是尚未完成的项目。

运行测试以查看这个测试失败。

  1. 为了使测试通过并修复错误,将tableView(_:didSelectRowAt:)的实现替换为以下代码:

    // ToDoItemsListViewController.swift
    func tableView(_ tableView: UITableView,
      didSelectRowAt indexPath: IndexPath) {
      let item: ToDoItem
      switch indexPath.section {
        case 0:
          let filteredItems = items.filter({ false ==
            $0.done })
          item = filteredItems[indexPath.row]
        default:
          let filteredItems = items.filter({ true ==
            $0.done })
          item = filteredItems[indexPath.row]
      }
      delegate?.selectToDoItem(self, item: item)
    }
    

在这个实现中,我们尊重两个部分,并相应地选择要显示的项目。

运行所有测试以确认现在所有测试都通过。

并且,随着最后一个错误的修复,我们完成了使用 TDD 创建的我们的小应用的第一简单版本。

修复表格视图的布局

这里的问题是,我们使用Combine框架来更新表格视图。当用户点击doToItemStore时,它会更新项目并通知表格视图。这导致表格视图在屏幕不可见时更新。这很容易修复,我们甚至不需要为这个错误编写测试。将以下代码添加到ToDoItemsListViewController中:

// ToDoItemsListViewController.swift
override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  token = toDoItemStore?.itemPublisher
    .sink(receiveValue: { [weak self] items in
      self?.items = items
      self?.update(with: items)
    })
}
override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  token?.cancel()
}

当带有表格视图的视图消失时,我们取消订阅itemsPublisher。当视图出现在屏幕上时,我们再次订阅。在模拟器中再次运行应用,查看控制台以查看消息是否已消失。

摘要

在本章的最后一章中,我们实现了应用中不同视图之间的导航。我们学习了如何测试将视图控制器推入导航堆栈,以及如何测试一个视图是否以模态方式呈现。

在实现导航后,我们在模拟器上启动了应用,找到了并修复了错误。我们发现 TDD 甚至有助于修复错误。通过首先编写一个针对该错误的失败测试,然后使测试通过,我们确保这个错误不会在未来影响我们的应用。

通过本章获得的知识,你将能够使用协调器模式实现和测试应用的导航。现在,你也能够编写测试来查找错误,并通过使测试通过来修复错误。

恭喜你,你到达了这本书的结尾!我希望这本书是你成为测试驱动开发者的旅程的开始。你学习了如何测试Combine代码,并为视图控制器、视图、表格视图甚至 SwiftUI 代码编写测试。我相信这是你下一步的好基础。尝试给你的现有项目添加测试,并与你的同事讨论单元测试和 TDD 的优缺点。找到你自己的测试风格。

最重要的是,享受乐趣!

练习

  1. 修复在模拟器上测试应用时发现的错误。

  2. 添加一个功能,让用户可以在所有待办事项列表中勾选待办事项。

  3. 在亚马逊上为这本书写一篇评论。

posted @ 2025-10-24 10:07  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报