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
。这样做可以,但如果你忘记覆盖某个方法,你可能会意外地进行真正的网络调用。你也可能造成副作用,比如缓存假的网络响应。 - 类似于你
mock
URLSession
的方式,你可以创建一个网络客户端协议,并直接使用它来代替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
的网络客户端,而不是通过子类和重写来创建。通过这样做,你避免了意外地进行真正的网络调用,以及诸如缓存等副作用。
你现在可以在屏幕上显示网络结果了 如果你也能看到小狗们的图像,而不是只有一个占位符的图像,那不是很好吗?你肯定会的! 在下一章中,你将学习如何创建一个图像客户端来帮助你做到这一点。