9:使用网络客户端¶
在上一章中,你发现ListingsViewController实际上并没有进行任何联网。相反,它在refreshData()中有一个//TODO注释。作为回应,你创建了DogPatchClient来处理网络逻辑。然而,你还没有使用它。
在本章中,你将更新ListingsViewController,以便在刷新时使用DogPatchClient具体来说,你将:
- 在
DogPatchClient上添加一个共享实例。 - 在
ListingsViewController上添加一个网络客户端属性。 - 创建一个网络客户端协议。
- 使用该协议创建一个
mock的网络客户端。 - 用
mock的方式来存根和验证行为。
开始工作¶
请自由使用你在上一章中的项目。如果你想重新开始,可以导航到本章的起始目录,打开DogPatch子目录,然后打开DogPatch.xcodeproj。
一旦你的项目准备好了,就该跳进去了,通过添加一个共享实例来设置DogPatchClient的联网。
创建一个共享实例¶
虽然你可以直接实例化DogPatchClient,但这有缺点:
- 你必须在实例化
DogPatchClient的任何地方重复创建数据,包括基本URL、会话和响应队列。 - 你会并行地进行更多的网络调用。因此,你可能会使用更多的网络数据或损害电池寿命。
一个更好的选择是在DogPatchClient上添加一个静态共享属性。这使用了"单例Plus"模式。你会在大多数时候使用共享实例,但你也会创建一次性的DogPatchClient实例,例如在你的单元测试中。
在你写应用代码之前,你首先需要写一个失败的测试。打开DogPatchClientTests.swift,在test_init_sets_baseURL()之前添加这个测试,像往常一样忽略编译器错误:
func test_shared_setsBaseURL() {
// given
let baseURL = URL(string: "https://dogpatchserver.herokuapp.com/api/v1/")!
// then
XCTAssertEqual(DogPatchClient.shared.baseURL, baseURL)
}
你首先创建一个预期的baseURL;这个地址对应于你将要调用的真正的服务器URL。然后你断言DogPatchClient.shared.baseURL等同于这个baseURL。然而,由于你还没有在DogPatchClient上定义shared,这就无法编译了。
编译器错误算作一个失败的测试,所以你可以写应用代码来修复它。
打开DogPatchClient.swift,在init(baseURL:session:responseQueue:)前添加以下内容:
static let shared = DogPatchClient(
baseURL: URL(string:"https://example.com")!,
session: URLSession(configuration: .default),
responseQueue: nil)
在这里,你定义了一个静态的共享属性,其输入值是假的。这足以修复单元测试中的编译器错误。
建立并运行单元测试,正如预期的那样,最后一个测试失败了。这是因为DogPatchClient.shared上设置的baseURL不等于预期的baseURL。为了使其通过,用下面的内容替换shared上的baseURL值:
baseURL: URL(string:"https://dogpatchserver.herokuapp.com/api/v1/")!
Warning
由于URL(string:relativeTo:)解析URL的方式,你必须在URL字符串的末尾加上尾部的斜线。如果你不这样做,在getDogs中创建的URL将不包括路径中的v1,因此,服务器将无法识别它。
构建并运行单元测试,现在它们应该都通过了。然而,你还需要几个测试来确保你已经为DogPatchClient.shared设置了正确的值。
在test_shared_setsBaseURL()下面添加以下测试:
func test_shared_setsSession() {
XCTAssertTrue(DogPatchClient.shared.session === URLSession.shared)
}
这个测试验证了DogPatchClient.shared.session与URLSession.shared的指针相等。构建并运行测试,你会看到这个测试如预期般失败。为了使其通过,打开DogPatchClient.swift,并将session的输入参数更新为URLSession.shared。再次构建并运行测试,并验证它们全部通过。
最后,在test_shared_setsSession()下面添加以下测试:
func test_shared_setsResponseQueue() {
XCTAssertEqual(DogPatchClient.shared.responseQueue, .main)
}
这个测试检查共享实例的最后一个属性,responseQueue。建立并运行这个测试,你会看到DogPatchClient.shared.responseQueue目前被设置为nil。要解决这个问题,将responseQueue的输入参数参数更新为.main。构建并再次运行测试,以验证它们全部通过。
最终,DogPatchClient的静态共享属性应该如下所示:
static let shared = DogPatchClient(
baseURL: URL(
string:"https://dogpatchserver.herokuapp.com/api/v1/")!,
session: URLSession.shared,
responseQueue: .main)
添加一个网络客户端属性¶
接下来,你需要给ListingsViewController添加一个networkClient属性。当然,在你写应用代码之前,你需要一个失败的测试。
打开ListingsViewControllerTests.swift,在// MARK: - Instance Properties - Tests后面添加以下内容:
func test_networkClient_setToDogPatchClient() {
XCTAssertTrue(sut.networkClient === DogPatchClient.shared)
}
你断言sut.networkClient与DogPatchClient.shared的指针相等。因为你还没有在ListViewController上定义networkClient,所以这个测试还不能编译。
为了解决这个问题,打开ListingsViewController.swift,在// MARK: - Instance Properties后面添加以下属性:
var networkClient = DogPatchClient(baseURL: URL(string: "http://example.com")!,
session: URLSession.shared,
responseQueue: nil)
你把它声明为一个var,以允许你的测试在以后用一个mock对象来替换它。通过定义这个属性,你也修正了编译器的错误。
构建并运行单元测试,你会看到这个测试失败了,因为networkClient没有被设置为DogPatchClient.shared。为了使测试通过,将ListingsViewController.swift中的var networkClient的声明替换为以下内容:
var networkClient = DogPatchClient.shared
构建并再次运行你的测试,以验证它们全部通过。
使用网络客户端¶
虽然你可以在单元测试中直接使用DogPatchClient,但这有几个缺点:
- 你会进行真正的网络调用,这需要一个互联网连接。
- 如果互联网连接不可用或者服务器宕机,测试就会失败。
- 你无法提前预测网络响应,所以你无法验证值是否是你所期望的。
- 你的单元测试会运行得很慢,因为每个测试都需要等待网络响应。
幸运的是,有一个更好的选择。使用一个mock的网络客户端。这可以让你避免进行真正的网络调用,同时完全控制响应结果。
有两种方法可以在Swift中创建一个mock的网络客户端:
- 你可以通过子类化
DogPatchClient并重写其每个方法来创建一个mock。这样做可以,但如果你忘记覆盖某个方法,你可能会意外地进行真正的网络调用。你也可能造成副作用,比如缓存假的网络响应。 - 类似于你
mockURLSession的方式,你可以创建一个网络客户端协议,并直接使用它来代替DogPatchClient。因此,你会消除进行真正的网络调用或造成副作用的可能性。很好! 唯一的缺点是,你需要创建一个额外的协议,但这是非常快速和容易做到的。
一般来说,你应该倾向于使用协议创建一个mock的网络客户端,而不是子类化和覆盖。
你可能选择子类化和覆盖的一个原因是,你的应用程序与网络客户端或其相关类型紧密耦合。例如,如果你正在处理一个有很多未经测试的代码的遗留应用。即使如此,从长远来看,你也应该努力使用协议来取代这一点。
创建网络客户端协议¶
你应该把什么放在网络客户端协议中?任何调用者需要使用的方法和属性! 反过来,你就可以用你的mock来验证你对这些的调用是否正确。
好了,理论已经够多了!你已经准备好TDD了。你现在准备好TDD协议了。
像往常一样,你要先写一个测试。打开DogPatchClientTests.swift,在test_shared_setsBaseURL()之前添加以下方法,忽略由此产生的编译器错误:
func test_conformsTo_DogPatchService() {
XCTAssertTrue((sut as AnyObject) is DogPatchService)
}
你把sut转成AnyObject,以防止出现编译器警告,然后你断言sut是DogPathcService。然而,这导致了一个编译器错误,因为你还没有定义DogPatchService。
为了解决这个问题,打开DogPatchClient.swift,在类声明之前添加以下内容:
protocol DogPatchService {
}
建立并运行单元测试,正如预期的那样,最后一个测试将失败。为了让它通过,在DogPatchClient.swift的结尾处,在结束类的大括号后添加以下内容:
extension DogPatchClient: DogPatchService { }
构建并再次运行测试,并验证它们全部通过。
这个协议还不是很有用,因为它没有任何方法。为此,在test_conformsTo_DogPatchService()下面添加以下测试:
func test_dogPatchService_declaresGetDogs() {
// given
let service = sut as DogPatchService
// then
_ = service.getDogs() { _, _ in }
}
这个测试不会被编译,因为DogPatchService对getDogs一无所知。为了解决这个问题,在DogPatchService协议中加入以下内容:
func getDogs(completion:@escaping ([Dog]?, Error?) -> Void) -> URLSessionTaskProtocol
现在构建并运行你的测试,它们应该全部通过。
但是,等等! 这个测试不会导致真正的网络调用吗?不会,如果你检查setUp(),你会记得你传递了一个MockSession的实例来创建DogPatchClient,并将其设置为sut。由于MockSession没有进行任何真正的网络调用,你可以自由地调用DogPatchClient上的方法而不用担心。
你还应该给DogPatchService添加其他属性或方法吗?例如,init(baseURL:session:responseQueue:)或共享属性呢?
不,你不需要添加这些,因为它们是实现细节。消费者不需要知道你是如何构建其依赖关系的。相反,它只需要知道这个依赖关系提供了什么行为。这反过来又定义了哪些方法和属性会进入协议。
现在,这一个方法就是你在DogPatchService中所需要的一切!
创建模拟网络客户端¶
接下来你需要创建mock的网络客户端。你的第一步是写一个测试,用于... 哦,等等! 你不需要一个测试。]
就像MockURLSession一样,你的mock网络客户端不会成为你应用程序代码的一部分。相反,它使你能够编写单元测试,而这反过来又使你能够编写应用程序代码。好了,继续吧...!
在DogPatchTests/Test Types/Mocks下,创建一个新的Swift文件,名为MockDogPatchService,并将其内容改为以下内容:
@testable import DogPatch
import Foundation
// 1
class MockDogPatchService: DogPatchService {
// 2
var baseURL = URL(string: "https://example.com/api/")!
var getDogsCallCount = 0
var getDogsCompletion: (([Dog]?, Error?) -> Void)!
lazy var getDogsDataTask = MockURLSessionTask(
completionHandler: { _, _, _ in },
url: URL(string: "dogs", relativeTo: baseURL)!,
queue: nil)
// 3
func getDogs(completion: @escaping ([Dog]?, Error?) -> Void)-> URLSessionTaskProtocol {
getDogsCallCount += 1
getDogsCompletion = completion
return getDogsDataTask
}
}
以下是你所做的事情:
- 你为
MockDogPatchService创建一个符合DogPatchService的新类型。 - 你为
baseURL、getDogsCallCount、getDogsCompletion和getDogsDataTask添加属性。你将使用它们来验证mock的调用是否符合预期,并返回存根的响应。 - 你实现
getDogs(completion:),这是DogPatchService要求的。每当它被调用时,你将增加getDogsCallCount,设置getDogsCompletion并返回getDogsDataTask。
太棒了,你像个专家一样实现了这个mock! 它反映了DogPatchClient的工作方式,但它允许你完全控制返回的响应,不需要网络连接,也没有任何网络延迟。所以现在,是时候让它发挥作用了。
使用模拟网络客户端¶
你终于准备好使用mock网络客户端了!
打开ListingsViewControllerTests.swift,你会看到已经包含了对现有功能的几个测试。你的工作是为refreshData()做TDD。
你的第一个测试将断言,视图控制器持有返回的数据任务。要做到这一点,在test_viewWillAppear_calls_refreshData()之后添加以下代码:
func test_refreshData_setsRequest() {
// given
let mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
}
在这里,你创建了mockNetworkClient并试图将其设置为sut.networkClient。不幸的是,这导致了一个编译器错误。这到底是怎么回事呢?
Xcode实际上给出了一个有用的错误信息:
Cannot assign value of type 'MockDogPatchService' to type 'DogPatchClient'
编译器期望networkClient是DogPatchClient类型,但你试图将其设置为MockDogPatchClient,而MockDogPatchClient并不继承于DogPatchClient。为了解决这个错误,你需要明确地将networkClient的类型设置为DogPatchService。
打开ListingsViewController.swift,替换这一行:
var networkClient = DogPatchClient.shared
为以下代码:
var networkClient: DogPatchService = DogPatchClient.shared
MockDogPatchService和DogPatchClient都符合DogPatchService,所以这消除了编译器错误。然而,你会注意到test_networkClient_setToDogPatchClient不再被编译,因为Swift不能使用相同的运算符===来比较DogPatchClient和DogPatchService。要解决这个问题,你需要把协议类型投给你想比较的对象类型。将test_networkClient_setToDogPatchClient的内容替换为:
XCTAssertTrue((sut.networkClient as? DogPatchClient) === DogPatchClient.shared)
运行你的测试,它们应该又都通过了。
接下来,在test_refreshData_setsRequest()中添加这段代码,就在它的结束方法括号之前:
// when
sut.refreshData()
// then
XCTAssertTrue(sut.dataTask === mockNetworkClient.getDogsDataTask)
在你调用sut.refreshData()后,这将检查sut.dataTask是否设置为mockNetworkClient.getDogsDataTask。然而,由于你没有在ListingsViewController上声明dataTask,这就不能编译了。为了解决这个问题,打开ListingsViewController.swift,在var viewModels后面添加以下内容:
var dataTask: URLSessionTaskProtocol?
这修复了编译器的错误,所以构建并运行测试,验证它是否失败。为了使其通过,你需要在调用refreshData()时设置dataTask。
将refreshData()的内容替换为以下内容:
dataTask = networkClient.getDogs() { dogs, error in
}
构建并再次运行测试,它们将全部通过。
refreshData有可能在快速连续的情况下被调用多次。例如,如果用户在一个网络调用已经开始的情况下"拉动刷新",就会发生这种情况。
如果dataTask已经被设置,你不想多次调用getDogs。在test_refreshData_setsRequest()之后添加以下测试;它确保你只调用getDogs一次,即使refreshData被快速连续调用:
func test_refreshData_ifAlreadyRefreshing_doesntCallAgain() {
// given
let mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
// when
sut.refreshData()
sut.refreshData()
// then
XCTAssertEqual(mockNetworkClient.getDogsCallCount, 1)
}
这个测试连续调用refreshData两次来mock这种情况。
构建并运行单元测试来验证这个测试的失败。为了让它通过,打开ListingsViewController.swift,在refreshData()的开头大括号后添加以下代码:
guard dataTask == nil else { return }
如果dataTask不是nil,这个防护措施会提前返回。构建并运行你的单元测试,它们现在应该都通过了。
你发现有什么需要重构的地方吗?应用程序的代码看起来不错,但单元测试呢?是的,你重复了设置sut.networkClient为mockNetworkClient的代码。
为了消除这种重复,首先在var sut行之后添加这个新属性:
var mockNetworkClient: MockDogPatchService!
接下来,在givenDogs(count:)方法之后添加以下方法:
func givenMockNetworkClient() {
mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
}
你不会在每个测试中都需要一个MockDogPatchService,所以你添加这个辅助方法来创建和设置一个。你将只在需要mock的测试中调用这个方法。
然后,在tearDown()中添加以下内容,就在其开头的方法括号之后:
mockNetworkClient = nil
这样可以确保每次测试运行完成后,mockNetworkClient被设置为nil。
最后,在test_refreshData_setsRequest和test_refreshData_ifAlreadyRefreshing_doesntCallAgain中替换以下两行:
let mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
用这一句话代替:
givenMockNetworkClient()
这样就摆脱了重复的代码。现在,构建并运行测试以验证它们仍然通过。
对于下一个测试,你需要确保在完成对getDogs的调用后,dataTask被设回为nil。在test_refreshData_ifAlreadyRefreshing_doesntCallAgain()下面添加以下测试:
func test_refreshData_completionNilsDataTask() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
XCTAssertNil(sut.dataTask)
}
下面是这个测试的工作原理:
- 在
given的部分中,你很好地利用了你的辅助方法来创建mockNetworkClient和dogs。 - 在
when时,你首先调用sut.refreshData()来设置dataTask。然后,你将dogs传递给mockNetworkClient上的getDogsCompletion闭包。这将执行从ListingsViewController传入的闭包,并且它应该将dataTask设置为nil。 - 在
then时,你断言sut.dataTask又被设置为nil。
建立并运行这个测试,你会发现它失败了。当然,这是因为你没有在getDogs的完成闭包中真正地将dataTask设置为nil。
为了使测试通过,请在ListingsViewController的refreshData中的完成闭包内添加这行:
self.dataTask = nil
构建并运行测试,验证最后一个测试现在通过了。
你现在可以测试"快乐的路径"了,它成功地返回狗,并在ListingsViewController上设置它。在test_refreshData_completionNilsDataTask()下面添加以下测试:
func test_refreshData_givenDogsResponse_setsViewModels() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
let viewModels = dogs.map { DogViewModel(dog: $0) }
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
XCTAssertEqual(sut.viewModels, viewModels)
}
下面是这个测试的工作原理:
- 在
given的范围内,你使用你的辅助方法来创建mockNetworkClient和dog,然后你通过将每个dog映射到DogViewModel来创建viewModels。 - 对于
when部分,你调用sut.refreshData()并执行getDogsCompletion与given的dog。 - 最后在
then中,你断言sut.viewModels等于viewModels。
构建并运行这个测试来验证它的失败。你需要在ListingsViewController上设置viewModels以使其通过。
在ListingsViewController的refreshData()中,在dataTask = nil之后添加以下内容:
self.viewModels = dogs?.map { DogViewModel(dog: $0) } ?? []
这同样也是调用map来把dogs变成DogViewModel数组。如果出现错误,dogs可能是nil,所以你使用可选的unwrap操作符??并提供默认值作为一个空数组。
建立并运行你的测试,你会看到最后一个测试现在通过了。这里还有什么需要重构的吗?嗯,也许...
乍一看,test_refreshData_completionNilsDataTask和test_refreshData_completionNilsDataTask之间的代码看起来差不多。然而,你已经将几个辅助方法分解出来了,而且你在这里使用它们。
虽然你可以尝试进一步重构这些测试,但你可能会使它们更难读。因此,让它们保持原样也是可以的。在尽可能多的重构和内联可读性之间总是有一个平衡点。如果你有疑问,就试试重构吧 如果发现代码太难读了,你可以随时撤销修改。
在下一个测试中,你要验证你在viewModels被设置后重新加载tableView。在test_refreshData_givenDogsResponse_setsViewModels()下面添加以下测试:
func test_refreshData_givenDogsResponse_reloadsTableView() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
// 1
class MockTableView: UITableView {
var calledReloadData = false
override func reloadData() {
calledReloadData = true
}
}
// 2
let mockTableView = MockTableView()
sut.tableView = mockTableView
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
// 3
XCTAssertTrue(mockTableView.calledReloadData)
}
这个测试有三个重要部分。
- 首先,你创建一个
MockTableView来覆盖reloadData()。在这里面,你为calledReloadData更新一个布尔值。 - 接下来,你为
mockTableView创建一个新的实例,并将其设置为sut.tableView,以确保它被使用。 - 最后,在你调用
refreshData()并执行getDogsCompletion后,你断言mockTableView.calledReloadData为真。
建立并运行单元测试,你会发现这个测试失败了,因为你目前没有在tableView上调用reloadData。为了让这个测试通过,在ListingsViewController的refreshData()中设置viewModels之后,添加以下一行:
self.tableView.reloadData()
构建并再次运行测试,现在最后一项将通过。
好了,你终于准备好检查这个应用程序了。建立并运行它! 你会看到那些狗......没有出现?
相反,视图控制器显示了一个错误屏幕,如果你"下拉刷新",你会看到"加载指示灯"从未消失。

这是因为你实现tableView(_:numberOfRowsInSection:)的方式。它考虑了tableView是否在刷新。
为了解决这个问题,你需要在表视图的refreshControl上开始和结束刷新。在test_refreshData_givenDogsResponse_reloadsTableView()下面添加以下测试:
func test_refreshData_beginsRefreshing() {
// given
givenMockNetworkClient()
// when
sut.refreshData()
// then
XCTAssertTrue(sut.tableView.refreshControl!.isRefreshing)
}
该测试验证了在调用refreshData()后,refreshControl的isRefreshing为真。建立并运行这个测试,它将会失败,因为你还没有开始刷新。
要解决这个问题,请在ListingsViewController的refreshData()中的guard语句后添加以下一行:
tableView.refreshControl?.beginRefreshing()
构建并再次运行测试,它们将全部通过。
最后,只要你的代码调用完成闭包,你就需要结束刷新。在test_refreshData_beginsRefreshing()下面添加以下测试来验证这一点:
func test_refreshData_givenDogsResponse_endsRefreshing() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
XCTAssertFalse(sut.tableView.refreshControl!.isRefreshing)
}
这个测试调用refreshData(),执行getDogsCompletion闭包,并断言refreshControl上的isRefreshing为false。构建并运行这个测试,它将失败,因为你实际上还没有完成刷新。
为了让测试通过,在ListingsViewController的refreshData()中设置viewModels后添加以下一行:
self.tableView.refreshControl?.endRefreshing()
再次构建并运行你的测试,它们应该都能通过。
干得好! 你已经为整个refreshData()的实现做了TDD。构建并运行该应用,看看它的运行情况。

关键点¶
在本章中,你学会了如何使用网络客户端进行TDD。以下是你所涉及的关键点。
- 你为网络客户端创建了一个共享实例,以避免在整个应用中出现多个实例。
- 你避免在单元测试中直接使用真实的网络客户端,因为这需要一个互联网连接,这将导致他们更慢,使测试响应更难。你使用了一个
mock的网络客户端来代替。 - 你学到了为什么通过实现一个协议来创建一个
mock的网络客户端,而不是通过子类和重写来创建。通过这样做,你避免了意外地进行真正的网络调用,以及诸如缓存等副作用。
你现在可以在屏幕上显示网络结果了 如果你也能看到小狗们的图像,而不是只有一个占位符的图像,那不是很好吗?你肯定会的! 在下一章中,你将学习如何创建一个图像客户端来帮助你做到这一点。