8:RESTful网络¶
在本章中,你将学习如何TDD
一个RESTful
网络客户端。具体来说,你将。
- 设置网络客户端。
- 确保正确的端点被调用。
- 处理网络错误、有效响应和无效响应。
- 将结果分配到响应队列中。
兴奋吧! TDD网络的神奇之处正在向你走来。
开始工作¶
导航到本章的起始目录。你会发现它有一个DogPatch
的子目录,包含DogPatch.xcodeproj
。在Xcode
中打开这个项目文件并看一下。
你会看到有几个文件在等着你。本章的重要文件是:
Controllers/ListingsViewController.swift
显示获取的狗或错误。Models/Dog.swift
是代表每个小狗的模型。Networking
暂时是一个空文件夹。你将在这里添加网络客户端和相关类型。
构建并运行该应用程序,下面的错误信息屏幕将迎接你:
如果你下拉刷新,活动指示器会产生动画,但永远不会完成。
打开ListingsViewController.swift
。你会看到tableView(_:numberOfRowsInSection:)
返回viewModels.count
的最大值或1
,如果它目前没有刷新。
同样,tableView(_:cellForRowAt:)
检查viewModels.count
是否大于0
,这将永远是假的,因为应用程序现在没有设置viewModels
。相反,你需要从网络响应中创建这些。
然而,这里有一个注释:// TODO:在refreshData()
中写这个,所以应用程序没有进行任何网络调用......
你的工作现在很清楚了 你需要编写逻辑来进行网络调用。虽然你可以直接在ListingsViewController
中进行一次性的网络调用,但这个视图控制器会很快变得非常大。
一个更好的选择是创建一个单独的网络客户端,处理所有的网络逻辑,这恰好是本章的重点!
设置网络客户端¶
在你写任何生产代码之前,你首先需要写一个失败的测试。
在DogPatchTests/Cases/Networking
中,创建一个名为DogPatchClientTests.swift
的新Swift
文件。将其内容替换为以下内容,暂时忽略编译器错误:
@testable import DogPatch
import XCTest
class DogPatchClientTests: XCTestCase {
var sut: DogPatchClient!
}
在这里,你为DogPatchClientTests
创建了一个新的测试类,有一个DogPatchClient
类型的sut
的单一属性。然而,由于你还没有创建DogPatchClient
,这段代码就不能编译了。编译器错误算作测试失败,所以你现在可以编写生产代码。
在DogPatch/Networking
中,创建一个名为DogPatchClient.swift
的新Swift
文件,并将其内容替换为以下内容:
import Foundation
class DogPatchClient {
}
在这里,你为DogPatchClient
声明一个新的类,从而解决了编译器错误。没有什么可重构的,所以你只需继续进行你的第一个测试。
打开DogPatchClientTests.swift
,在sut
的声明下面添加以下内容,同样忽略了编译器错误:
func test_init_sets_baseURL() {
// given
let baseURL = URL(string: "https://example.com/api/v1/")!
// when
sut = DogPatchClient(baseURL: baseURL)
}
最终,你想测试传入初始化器的baseURL
是否与sut.baseURL
匹配。然而,你还没有创建这个初始化器,所以这就不能编译了。
要解决这个问题,请在DogPatchClient
中添加以下内容:
let baseURL = URL(string: "https://example.com/")!
init(baseURL: URL) {
}
你声明了baseURL
,暂时把它设置为一个任意的值,然后创建init(baseURL:)
,这就足以让测试编译了。但是你还没有断言什么。
在DogPatchClientTests
中,在测试方法的末尾添加以下内容:
// then
XCTAssertEqual(sut.baseURL, baseURL)
你断言sut.baseURL
等于传递给初始化器的baseURL
。建立并运行单元测试。正如预期的那样,这个测试失败了。
为了使其通过,将DogPatchClient
内的let baseURL =
一行替换为:
let baseURL: URL
然后在init(baseURL:)
中添加以下内容:
self.baseURL = baseURL
你从传入的参数中设置baseURL
到初始化器中。构建并运行你的测试,现在通过了。没有任何东西需要重构,所以只需继续。
你还需要一个URLSession
的属性,你将用它来进行网络调用。
在之前的测试之后添加以下测试,再次忽略编译器错误:
func test_init_sets_session() {
// given
let baseURL = URL(string: "https://example.com/api/v1/")!
let session = URLSession.shared
// when
sut = DogPatchClient(baseURL: baseURL, session: session)
}
这个测试的目的是扩展初始化器以接受一个会话参数。
像以前一样,你没有在DogPatchClient
上声明会话,所以这不能编译。要解决这个问题,请在DogPatchClient
的baseURL
之后添加以下属性:
let session: URLSession = URLSession(configuration: .default)
接下来,将init(baseURL:)
的方法签名更新为:
init(baseURL: URL, session: URLSession)
这让test_init_sets_session()
可以编译,但它破坏了test_init_sets_baseURL()
。为了解决这个问题,在test_init_sets_baseURL()
中的let baseURL
下面添加这一行:
let session = URLSession.shared
然后更新sut = to
的行:
sut = DogPatchClient(baseURL: baseURL, session: session)
你的测试现在再次编译了,但你还没有给test_init_sets_session()
添加断言。在该测试方法的末尾添加以下内容:
// then
XCTAssertEqual(sut.session, session)
你断言sut.session
和session
是相等的。
建立并运行你的测试。正如预期的那样,这个测试失败了。为了使其通过,将DogPatchClient
上的session
的属性声明改为。
let session: URLSession
然后在初始化器的末尾添加这一行:
self.session = session
构建并运行测试,看到它们现在都通过了。这一次,你确实要做一些重构工作。
test_init_sets_baseURL()
和test_init_sets_session()
中的前几行是完全一样的。要解决这个问题,首先在类的顶部,在var sut
之前添加以下属性:
var baseURL: URL!
var session: URLSession!
接下来,在属性后面添加这两个方法:
override func setUp() {
super.setUp()
baseURL = URL(string: "https://example.com/api/v1/")!
session = URLSession.shared
sut = DogPatchClient(baseURL: baseURL, session: session)
}
override func tearDown() {
baseURL = nil
session = nil
sut = nil
super.tearDown()
}
你在setUp
中设置每个属性,在tearDown
中置空每个属性,这有助于你消除测试中的重复。
将test_init_sets_baseURL()
的全部内容替换为:
XCTAssertEqual(sut.baseURL, baseURL)
然后将test_init_sets_session()
的内容替换为:
XCTAssertEqual(sut.session, session)
建立并运行测试。你会看到它们都通过了。
优秀的工作! 你声明了两个属性!
好吧,也许这并不那么令人激动... 然而,这些属性对于进行网络调用是必不可少的,现在你可以写这些代码了
TDDing网络调用¶
你需要做一个GET
请求,从服务器上获取一个Dog
对象的列表。你将把它分解成几个小任务:
Mocking
URLSesssion
。- 调用正确的
URL
。 - 处理错误响应。
- 成功时对模型进行反序列化。
- 处理无效的响应。
Mocking URLSession¶
为了保持你的测试的快速和可重复性,不要在其中进行真正的网络调用。你将创建一个MockURLSession
,而不是使用一个真正的URLSession
,它将让你验证行为,但不会进行网络调用。你将把它传递给DogPatchClient
的初始化器,并像使用一个真正的URLSession
那样使用它。
你可能会想通过子类化和重写URLSession
来创建它。然而,如果你尝试这样做,你会发现它的init
已经被废弃了,而且它的其他初始化器也被标记为public
而不是open
。因此,你不能有效地对URLSession
进行子类化!
幸运的是,还有一个解决方案。你将创建一个URLSessionProtocol
并更新DogPatchClient
以使用它,而不是直接使用URLSession
。在生产代码中,你将使URLSession
符合URLSessionProtocol
,并将其传递给DogPatchClient
。在单元测试中,你将使MockURLSession
符合并传递它。
好了,你已经掌握了理论! 是时候写代码了。
创建会话协议¶
在DogPatch/Networking
中,创建一个新的Swift
文件,名为URLSessionProtocol.swift
,并将其内容替换为:
import Foundation
protocol URLSessionProtocol: AnyObject {
func makeDataTask(with url: URL,
completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void)-> URLSessionTaskProtocol
}
protocol URLSessionTaskProtocol: AnyObject {
func resume()
}
你声明的URLSessionProtocol
只有一个必要的方法,makeDataTask(with:completionHandler:)
,它返回一个URLSessionTaskProtocol
而不是直接返回一个真正的URLSessionTask
。这让你可以模拟URLSessionTask
并验证其行为。URLSessionTaskProtocol
也有一个必要的方法,即resume()
,用来启动网络任务。
但是等等,你不是缺少单元测试吗?没有! 协议没有具体的行为,所以没有什么可测试的。
遵循会话协议的规定¶
你接下来需要使URLSession
符合URLSessionProtocol
,而URLSessionTask
符合URLSessionTaskProtocol
。因为URLSessionProtocol
使用URLSessionTaskProtocol
,所以首先要让URLSessionTask
符合。
由于URLSessionTask
确实需要具体的实现代码,你需要为它编写测试。
在DogPatchTests/Cases/Networking
下,创建一个新的Swift
文件,名为URLSessionProtocolTests.swift
,并将其内容替换为:
import XCTest
@testable import DogPatch
class URLSessionProtocolTests: XCTestCase {
var session: URLSession!
override func setUp() {
super.setUp()
session = URLSession(configuration: .default)
}
override func tearDown() {
session = nil
super.tearDown()
}
func test_URLSessionTask_conformsTo_URLSessionTaskProtocol() {
// given
let url = URL(string: "https://example.com")!
// when
let task = session.dataTask(with: url)
// then
XCTAssertTrue((task as AnyObject) is URLSessionTaskProtocol)
}
}
你为URLSessionProtocolTests
创建了一个新的测试用例,有一个单一的属性--session
,以及一个验证URLSessionTask
是否符合URLSessionTaskProtocol
的单元测试。由于URLSessionTask
没有任何非废弃的初始化器,你不能直接创建它。相反,你可以调用session.dataTask(with:)
来创建一个。
建立并运行测试,你会看到这个测试失败了。为了让它通过,在URLSessionProtocol.swift
的末尾添加以下内容:
extension URLSessionTask: URLSessionTaskProtocol { }
你扩展URLSessionTask
,使其符合URLSessionTaskProtocol
。由于URLSessionTask
已经实现了resume()
,你不需要在这里添加它。构建并重新运行测试,并看到它们通过了。没有什么需要重构的,所以继续吧。
!!! note:
如果你熟悉URLSession
的来龙去脉,你知道dataTask(with:)
返回一个URLSessionDataTask
,而URLSessionTask
是它的超类。
通过使URLSessionTask
符合URLSessionTaskProtocol
,而不是URLSessionDataTask
,你也可以得到所有子类的一致性,包括URLSession
创建和返回的公共和内部类型。因此,这是一个更灵活、更好的设计。
你怎么能自己想出这个办法呢?通过使用TDD
和试错! 然而,这个过程对于本章的核心概念并不重要,所以为了简洁起见,我省略了它。
在URLSessionProtocolTests
中的前一个测试之后添加这个测试:
func test_URLSession_conformsTo_URLSessionProtocol() {
XCTAssertTrue((session as AnyObject) is URLSessionProtocol)
}
你验证会话,一个URLSession
的实例,符合URLSessionProtocol
。构建和运行测试,你会看到这是不合格的。
为了使其通过,在URLSessionProtocol.swift
的末尾添加以下内容:
extension URLSession: URLSessionProtocol {
func makeDataTask(with url: URL,
completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionTaskProtocol {
let url = URL(string: "http://fake.example.com")!
return dataTask(with: url,
completionHandler: { _, _, _ in } )
}
}
你扩展了URLSession
以符合URLSessionProtocol
,并通过调用dataTask
的假值来实现makeDataTask
。建立并运行测试,你会看到它们都通过了。仍然没有什么需要重构的,所以继续。
接下来,你需要验证makeDataTask
是否用传入的url
调用dataTask
。在上一个测试之后添加这个测试:
func test_URLSession_makeDataTask_createsTaskWithPassedInURL() {
// given
let url = URL(string: "https://example.com")!
// when
let task = session.makeDataTask(
with: url,
completionHandler: { _, _, _ in })
as! URLSessionTask
// then
XCTAssertEqual(task.originalRequest?.url, url)
}
你断言传入的url
与task.originalRequest
上的url
相符。构建并运行测试以确认这确实是失败的。
为了让它通过,将makeDataTask
的内容替换为:
return dataTask(with: url, completionHandler: { _, _, _ in } )
在这里,你更新返回语句,以使用传入的URL。建立并运行测试。最后一个测试现在将通过。
现在在URLSessionProtocolTests
中出现了重复的代码。你在两个不同的测试中创建了相同的url
。为了解决这个问题,声明这个新属性,就在session
下面:
var url: URL!
在setUp()
中,在设置session
后立即添加这一行,以创建URL
:
url = URL(string: "https://example.com")!
然后在tearDown()
中,在设置会话后添加这个,以清空url
:
url = nil
删除test_URLSessionTask_conformsTo_URLSessionTaskProtocol
和test_URLSession_makeDataTask_createsTaskWithPassedInURL
中的// given
和let url
行,以消除重复。建立并运行测试,看到它们都继续通过。
接下来,在最后一个测试之后添加这个测试:
func test_URLSession_makeDataTask_createsTaskWithPassedInCompletion() {
// given
let expectation = expectation(description: "Completion should be called")
// when
let task = session.makeDataTask(
with: url,
completionHandler: { _, _, _ in expectation.fulfill() }) as! URLSessionTask
task.cancel()
// then
waitForExpectations(timeout: 0.2, handler: nil)
}
这个测试验证completionHandler
的设置是否正确。因此,你创建了一个期望,并在completionHandler
被调用时实现它。通过调用task.cancel()
,你导致completionHandler
的执行。你通过waitForExpecations
验证期望的实现。
建立并运行测试,你会看到这个测试失败了。为了使其通过,请用以下内容更新makeDataTask
的内容:
return dataTask(with: url,
completionHandler: completionHandler)
你更新返回语句以使用传入的completionHandler
。建立并运行测试,并看到它们都通过了。
创建和使用session mock¶
接下来,你需要为MockURLSession
和MockURLSessionTask
创建测试类型。在Test Types/Mocks
下,创建一个名为MockURLSession.swift
的新Swift
文件。
将其内容替换为:
@testable import DogPatch
import Foundation
// 1
class MockURLSession: URLSessionProtocol {
func makeDataTask(with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?)
-> Void) -> URLSessionTaskProtocol {
// 2
return MockURLSessionTask(
completionHandler: completionHandler,
url: url)
}
}
class MockURLSessionTask: URLSessionTaskProtocol {
var completionHandler: (Data?, URLResponse?, Error?) -> Void
var url: URL
init(completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void,
url: URL) {
self.completionHandler = completionHandler
self.url = url
}
// 3
func resume() {
}
}
以下是你的做法:
- 你声明
MockURLSession
符合URLSessionProtocol
,并从makeDataTask
创建一个MockURLSessionTask
。 - 你创建的
MockURLSessionTask
符合URLSessionTaskProtocol
,声明url
和completionHandler
的属性并在其初始化器中设置这些属性。这样你就可以在你的测试中使用这些。 - 你现在将
resume
实现为一个空方法。
你没有向DogPatchClient
传递真正的URLSession
,而是传递一个MockURLSession
的实例。
为了明确这是个mock
,在DogPatchClientTests.swift
中,右键单击session
属性,选择Refactor -> Rename
,将其名称改为mockSession
。接下来,将var mockSession
一行替换为以下内容,暂时忽略编译器错误:
var mockSession: MockURLSession!
这段代码将mockSession
的变量类型改为MockURLSession
,但它在几个地方破坏了测试。首先,你需要更新你在setUp()
中设置这个属性的地方。
将mockSession =
一行改为:
mockSession = MockURLSession()
接下来,在DogPatchClient.swift
中,你需要更新会话的类型。首先,将let session
这一行替换为:
let session: URLSessionProtocol
接下来,将init(baseURL: URL, session: URLSession)
声明替换为:
init(baseURL: URL, session: URLSessionProtocol)
回到DogPatchClientTests.swift
中,将test_init_sets_session
的内容替换为:
XCTAssertTrue(sut.session === mockSession)
你使用身份操作符===
来断言sut.session
和mockSession
是同一个实例。
这些改变解决了所有的编译器错误。构建并运行测试,并验证它们全部通过。
调用正确的URL¶
现在,使用MockURLSession
来验证你的测试中的行为吧!
还是在DogPatchClientTests
中,在最后一个测试后添加以下测试,忽略编译器错误:
func test_getDogs_callsExpectedURL() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
// when
let mockTask = sut.getDogs() { _, _ in } as! MockURLSessionTask
}
这个测试验证了getDogs
调用一个特定的URL
。然而,它没有被编译,因为你在生产代码中还没有声明getDogs
。在DogPatchClient
的其他方法之后添加这个:
func getDogs(completion: @escaping([Dog]?, Error?) -> Void) -> URLSessionTaskProtocol {
return session.makeDataTask(with: baseURL) { _, _, _ in }
}
这个方法使用baseURL
和一个空的闭包作为假值调用session.makeDataTask(with:completionHandler:)
。你需要一个断言来验证正确的URL
被调用。打开DogPatchClientTests.swift
,在test_getDogs_callsExpectedURL()
的末尾添加这个:
// then
XCTAssertEqual(mockTask.url, getDogsURL)
构建并运行测试,你会看到这个失败。现在你可以写代码来调用正确的URL
。
打开DogPatchClient.swift
,将getDogs(completion:)
的内容替换为以下内容:
let url = URL(string: "dogs", relativeTo: baseURL)!
return session.makeDataTask(with: url) { _, _, _ in }
建立并运行测试,看到它们都通过了。
URLSession
在创建后不会启动一个网络任务。相反,你必须在任务上调用resume
。你需要一个测试来验证你是否调用了这个。
在你写这个测试之前,在MockURLSession.swift
中,将MockURLSessionTask
上的resume()
替换为以下内容:
var calledResume = false
func resume() {
calledResume = true
}
你为calledResume
声明了一个新的属性,默认为false
,并在resume()
中将其设置为true
。现在,你可以写一个使用这个的测试。
打开DogPatchClientTests.swift
,在最后一个测试后添加以下内容:
func test_getDogs_callsResumeOnTask() {
// when
let mockTask = sut.getDogs() { _, _ in } as! MockURLSessionTask
// then
XCTAssertTrue(mockTask.calledResume)
}
构建并运行,你会看到这个测试如预期般失败。为了使其通过,在DogPatchClientTests.swift
中,将DogPatchClient
的getDogs(completion:)
中的返回行替换为:
let task = session.makeDataTask(with: url) { data, response, error in
}
task.resume()
return task
处理错误响应¶
接下来,你需要处理错误响应。有两种情况表明发生了错误:
- 服务器返回一个除
200
以外的HTTP
状态码。这个端点在成功时总是返回200
。如果返回另一个状态码,说明请求失败。 - 请求可能永远不会到达服务器,可能超时或在网络层发生其他错误状况。在这种情况下,错误将被设置。
首先写一个测试,检查第一种情况。在最后一个测试之后添加以下测试:
func test_getDogs_givenResponseStatusCode500_callsCompletion() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 500,
httpVersion: nil,
headerFields: nil)
// when
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
let mockTask = sut.getDogs() { dogs, error in
calledCompletion = true
receivedDogs = dogs
receivedError = error
} as! MockURLSessionTask
mockTask.completionHandler(nil, response, nil)
// then
XCTAssertTrue(calledCompletion)
XCTAssertNil(receivedDogs)
XCTAssertNil(receivedError)
}
下面是你的做法:
- 在
given
中,你使用getDogsURL
创建响应,HTTP
状态为500
表示失败。 - 在
when
中,你创建了变量来保持完成闭包是否被调用以及返回值。然后你在mockTask
上调用completionHandler
。 - 在
then
中,你断言完成处理程序被调用了,并且收到的dog
和error
的值为零。
建立并运行你的测试。正如预期的那样,这个测试将失败,因为完成处理程序没有被调用。
为了解决这个问题,在DogPatchClient
的getDogs
的session.dataTask(with: url)
的闭包中添加以下内容:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200 else {
completion(nil, error)
return
}
这个保护语句检查状态代码是否是预期的200
结果,如果不是,则调用完成处理程序。
建立并运行你的测试。你的测试现在通过了。你看到有什么需要重构的吗?是的,getDogsURL
在两个测试中是完全一样的。
为了消除这种重复,在DogPatchClientTests
的sut
声明后添加以下计算属性:
var getDogsURL: URL {
return URL(string: "dogs", relativeTo: baseURL)!
}
然后从test_getDogs_callsExpectedURL
中删除整个给定部分,并从test_getDogs_givenResponseStatusCode500_callsCompletion
中删除让getDogsURL
一行。
建立并运行你的测试。他们将继续通过。
接下来,你将处理另一种错误情况,当错误返回时。添加下面的测试案例来检查这个问题:
func test_getDogs_givenError_callsCompletionWithError() throws {
// given
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let expectedError = NSError(domain: "com.DogPatchTests",
code: 42)
// when
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
let mockTask = sut.getDogs() { dogs, error in
calledCompletion = true
receivedDogs = dogs
receivedError = error as NSError?
} as! MockURLSessionTask
mockTask.completionHandler(nil, response, expectedError)
// then
XCTAssertTrue(calledCompletion)
XCTAssertNil(receivedDogs)
let actualError = try XCTUnwrap(receivedError as NSError?)
XCTAssertEqual(actualError, expectedError)
}
以下是你的做法:
- 在
given
的范围内,你创建了一个statusCode
为200
的响应和一个expectedError
。你不太可能有一个200
的成功响应代码和一个错误。然而,也许服务器的行为是不正确的,或者你在现实世界中碰到了某种边缘情况。嘿,服务器开发人员也不是完美的。不过从实际情况来看,这可以确保你之前对statusCode
的保护在这种情况下不会被触发。 - 在
when
中,你设置变量来检查是否调用了完成度以及收到了什么值。然后,你用之前的响应和预期错误调用mockTask
上的completionHandler
。 - 在
then
中,你断言完成任务被调用,收到的狗是空的,并且错误与你期望的一致。
建立并运行你的测试。calledCompletion
和unwrapping receivedError
的断言失败了,这是有道理的,因为你还没有写这个代码。
你也可以暂时将receivedDogs
的赋值改为空数组,以证明XCTAssertNil(receivedDogs)
失败,但在继续之前一定要将这个属性设回nil
。
要使所有的断言都通过,请将DogPatchClient
上getDogs
中的整个guard
替换为:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil else {
建立并运行你的测试。现在它们都会通过。然而,现在这个测试和前一个测试之间有很多代码重复。
为了解决这个问题,为共同的代码拉出一个辅助方法。在tearDown
后面添加以下方法,因为它被几个测试所调用:
func whenGetDogs(data: Data? = nil,
statusCode: Int = 200,
error: Error? = nil) -> (calledCompletion: Bool, dogs: [Dog]?, error: Error?) {
let response = HTTPURLResponse(url: getDogsURL,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil)
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
let mockTask = sut.getDogs() { dogs, error in
calledCompletion = true
receivedDogs = dogs
receivedError = error as NSError?
} as! MockURLSessionTask
mockTask.completionHandler(data, response, error)
return (calledCompletion, receivedDogs, receivedError)
}
下面是它的工作原理:
- 这个方法接受
data
、statusCode
和错误的输入。为了方便起见,你也可以为每个人提供适当的默认值。它返回一个包含calledCompletion
,dogs
和error
值的元组。 - 它使用
getDogsURL
和传入的statusCode
创建响应。 - 然后,它创建局部变量,在
sut
上调用getDogs
,在mockTask
上调用completionHandler
,就像之前的测试一样。 - 最后,该方法返回由
calledCompletion
、receivedDogs
和receivedError
的局部变量创建的元组。
你可以使用这个方法来删除测试中的重复代码。首先,将test_getDogs_givenResponseStatusCode500_callsCompletion
的内容替换为:
// when
let result = whenGetDogs(statusCode: 500)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
XCTAssertNil(result.error)
这个方法被简化了,因为大部分的工作现在发生在whenGetDogs
中。
接下来,将test_getDogs_givenError_callsCompletionWithError
的内容替换成这样:
// given
let expectedError = NSError(domain: "com.DogPatchTests",
code: 42)
// when
let result = whenGetDogs(error: expectedError)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
let actualError = try XCTUnwrap(result.error as NSError?)
XCTAssertEqual(actualError, expectedError)
这个方法也被简化了,现在只处理设置expectedError
和测试它正确返回的部分。
构建和运行测试,它们都将继续通过。这是一个很好的重构,你即将进行的测试将很好地利用这个辅助方法!
成功时对模型进行反序列化¶
你终于准备好处理快乐路径的情况了:处理一个成功的响应。
在这之前,项目中已经有一个方便的扩展,你应该了解一下。在DogPatchTests/Test Types/Extensions
下,打开Data+JSONFile.swift
。你会看到fromJSON(fileName:file:line:)
,一个从文件中获取数据的静态方法。
这个方法是用来测试的。如果找不到该文件,那么它将无法断言并抛出一个异常。例如,如果文件没有被添加,或者在方法中输入了错误的文件名,这就可能发生。
此外,一位好心的开发者同事已经在DogPatchTests/Data
中为你提供了一个名为GET_Dogs_Response.json
的测试数据文件。不客气:] 。
掌握了这些信息,你就可以写出happy-path
测试了! 在DogPatchClientTests
中的前一个测试后添加以下内容:
func test_getDogs_givenValidJSON_callsCompletionWithDogs() throws {
// given
let data = try Data.fromJSON(fileName: "GET_Dogs_Response")
let decoder = JSONDecoder()
let dogs = try decoder.decode([Dog].self, from: data)
// when
let result = whenGetDogs(data: data)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertEqual(result.dogs, dogs)
XCTAssertNil(result.error)
}
下面是这段代码的作用:
- 首先,你通过调用
Data.fromJSON
和给定的JSON
文件名来创建数据。 - 你创建一个新的
JSONDecoder
类型的解码器,用它来解码数据。这是有可能的,因为Dog
已经符合Decodable
,并且在DogTests.swift
中有测试验证其工作。 - 然后,你像其他测试一样调用
whenGetDogs
,但这一次,你把数据传给它。 - 最后,你断言该完成程序被调用,
dogs
等于result.dogs
,result.error
为nil
。
建立并运行你的测试,正如预期的那样,你会看到这个测试失败了。为了使其通过,将DogPatchClient
中getDogs(completion:)
中的guard
语句替换为:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil,
let data = data else {
这里的区别是,你添加了let data
作为守护的条件,使其通过。
然后在守护块的结尾大括号后添加以下内容:
let decoder = JSONDecoder()
let dogs = try! decoder.decode([Dog].self, from: data)
completion(dogs, nil)
这里的try!
语句看起来很危险,而且肯定是危险的!但这是使测试通过的最小代码量。然而,这是使测试通过的最小代码量,而且它是你需要另一个测试的指标。
建立并运行单元测试,它们都会通过。没有任何重构工作要做,但你需要摆脱这个try!
。
在什么情况下,这个try!
会成为一个问题呢?如果服务器返回一个200
的响应,但JSON
不能被解析成Dogs
,这将导致应用程序崩溃。
幸运的是,这正是单元测试可以捕捉并帮助你预防的问题类型。在前面的测试之后添加以下测试,以产生这种确切的场景:
func test_getDogs_givenInvalidJSON_callsCompletionWithError() throws {
// given
let data = try Data.fromJSON(fileName: "GET_Dogs_MissingValuesResponse")
var expectedError: NSError!
let decoder = JSONDecoder()
do {
_ = try decoder.decode([Dog].self, from: data)
} catch {
expectedError = error as NSError
}
// when
let result = whenGetDogs(data: data)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
let actualError = try XCTUnwrap(result.error as NSError?)
XCTAssertEqual(actualError.domain, expectedError.domain)
XCTAssertEqual(actualError.code, expectedError.code)
}
这里有一个代码分解:
- 你从
GET_Dogs_MissingValuesResponse
设置数据。这是一个有效的JSON
数组,但它缺少一个解串狗对象所需的ID
。 - 然后,你创建了一个
JSONDecoder
类型的解码器,并试图反序列化数据。你捕捉到被抛出的错误,并将其作为expectedError
。 - 你调用
whenGetDogs
,并断言该完成被调用,返回的dog
是nil
,并且错误的域和代码与expectedError
相同。你必须铸造到NSError
,因为Error
对象不能直接进行比较。通过铸造到NSError
,你可以将错误的域和代码相互比较,这就"足够好"地表明这是同一个错误。
构建并运行测试。这个测试不仅失败了,还崩溃了! 很好,你在做TDD
的时候发现了这个问题,而不是在代码运到生产地之后,对吗?
为了解决这个问题,在DogPatchClient
中替换这几行:
let dogs = try! decoder.decode([Dog].self, from: data)
completion(dogs, nil)
用这个代码代替:
do {
let dogs = try decoder.decode([Dog].self, from: data)
completion(dogs, nil)
} catch {
completion(nil, error)
}
建立并重新运行你的单元测试。看看它们现在都通过了。
派遣到响应队列¶
你的DogPatchClient
像个老板一样处理网络 只有一个问题:你一直在mock``URLSessionTask
以避免进行真正的网络调用,但不幸的是,你也掩盖了URLSessionTask
的一个行为。
URLSessionTask
在后台队列中调用它的关闭程序,这是有问题的,因为应用程序需要使用Dogs
或Error
结果来执行UI
操作,这发生在Main
队列中。
虽然你可以把它留给调用者来调度到主队列,但这只会把问题往下推,使网络客户端更难使用。一个更好的设计是让DogPatchClient
接受一个响应队列并处理调度。你甚至可以在不破坏现有单元测试的情况下,通过使responseQueue
成为可选项来做到这一点。
添加一个响应队列¶
在test_init_sets_session()
后面直接添加以下测试,暂时忽略编译器错误:
func test_init_sets_responseQueue() {
// given
let responseQueue = DispatchQueue.main
// when
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: responseQueue)
}
由于你没有在DogPatchClient
上定义responseQueue
,这个测试目前可以编译。啊,你刚才跳了这个舞!;] 。
要解决这个错误,请在DogPatchClient
之后添加以下属性:
let responseQueue: DispatchQueue? = nil
然后把init
的签名换成这个,忽略测试中产生的编译器错误:
init(baseURL: URL,
session: URLSessionProtocol,
responseQueue: DispatchQueue?)
测试不能编译,因为你需要更新setUp
中的设置sut
。将这一行改为:
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: nil)
最后,在test_init_sets_responseQueue()
的末尾添加这段代码:
// then
XCTAssertEqual(sut.responseQueue, responseQueue)
建立并运行测试,正如预期的那样,这个测试失败了。为了解决这个问题,将DogPatchClient
中的let responseQueue
一行替换为:
let responseQueue: DispatchQueue?
然后是init
内的这一行:
self.responseQueue = responseQueue
构建并重新运行你的测试,现在它们通过了。
更新mock¶
接下来,你需要更新MockURLSession
和MockURLSessionTask
来调用调度队列上的完成处理程序。在MockURLSession.swift
中,给MockURLSession
添加这个新属性:
var queue: DispatchQueue? = nil
接下来,在它下面添加这个方法:
func givenDispatchQueue() {
queue = DispatchQueue(label: "com.DogPatchTests.MockSession")
}
现有的测试不需要这个队列,所以你只为接下来要添加的新测试调用这个。
你还需要改变MockURLSessionTask
的初始化器。用下面的内容代替它,暂时忽略编译器的错误:
init(completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void,
url: URL,
queue: DispatchQueue?) {
然后,在init
中替换这一行:
self.completionHandler = completionHandler
用这个代码代替:
if let queue = queue {
self.completionHandler = { data, response, error in
queue.async() {
completionHandler(data, response, error)
}
}
} else {
self.completionHandler = completionHandler
}
如果一个队列传入初始化器,在调用completionHandler
之前,你设置self.completionHandler
异步分派到队列。这与真正的URLDataTask
派发到派发队列的方式类似。
为了解决编译器的错误,在MockURLSession
的makeDataTask
中用以下语句代替返回语句:
return MockURLSessionTask(
completionHandler: completionHandler,
url: url,
queue: queue)
这段代码将队列传递给MockURLSessionTask
的新初始化器。
建立并运行你的单元测试。因为没有一个测试依赖于完成处理程序被调用的队列,所以它们都会继续通过。
处理派发的情况¶
接下来,你需要验证completionHandler
分派到responseQueue
,在这些情况下应该发生:
- 一个
HTTP
状态代码表示一个失败的响应。 - 收到一个
HTTP
错误。 - 收到一个有效的
JSON
响应,并成功反序列化。 - 收到一个无效的
JSON
响应,并且反序列化失败。
对于第一种情况,在DogPatchClientTests.swift
中,在现有的测试之后添加以下测试:
func test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
let expectation = self.expectation(description: "Completion wasn't called")
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 500,
httpVersion: nil,
headerFields: nil)
mockTask.completionHandler(nil, response, nil)
// then
waitForExpectations(timeout: 0.1) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
下面是这段代码的工作原理:
- 在
given
部分,你调用mockSession.givenDispatchQueue
来设置mockSession
上的队列。反过来,它使用这个来创建一个MockURLSessionTask
。你还创建了一个sut
,把.main
作为响应队列传给它。最后,你创建了一个期望,稍后你会用它来等待completionHandler
被调用。
从技术上讲,你可以使用任何响应队列。实用地讲,你需要把完成处理程序分派给主队列。可悲的是,iOS
让你很难检查代码目前在哪个队列上运行...... 哦,苹果!你不知道我们需要这个吗?难道你不知道我们在单元测试中需要这个吗?
幸运的是,很容易验证当前线程是主线程,而主调度队列总是在主线程上运行。因此,你的测试依靠这一事实来验证代码被"派发到主队列"。
当然,在现实中,你在技术上检查代码是否运行在主线程上。然而,如果苹果公司没有让它更容易测试和验证哪个调度队列正在使用,这就"足够好了"。
- 在
when
时,你首先为线程创建一个局部变量,然后调用sut.getDogs()
。在其完成处理程序中,你设置了线程并实现了期望。
然后,你创建一个错误状态代码为500
的响应变量,并使用它来调用completionHandler
。
- 然后,你调用
waitForExpectations
来等待期望的实现。在等待处理程序中,你断言该线程是主线程。
喔,这真是一个大手笔的测试 构建并运行单元测试,你会发现这个测试失败了,因为你目前没有向DogPatchClient
的responseQueue
分派。
要解决这个问题,首先在DogPatchClient
的getDogs(completion:)
中替换这一行:
let task = session.makeDataTask(with: url) { data, response, error in
用下面的代码,无视这个警告:
let task = session.makeDataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }
通过以这种方式使用[weak self]和guard let self
,你避免了产生强引用循环,而如果你直接引用self
,则可能产生强引用循环。
接下来,在guard let response
闭包内,替换这段代码的第一个实例,其他两个完成点不做改动:
completion(nil, error)
用这个代码:
guard let responseQueue = self.responseQueue else {
completion(nil, error)
return
}
responseQueue.async {
completion(nil, error)
}
这段代码检查是否设置了responseQueue
,如果设置了,则将调用分派给完成。
构建并运行单元测试,并观察它们是否全部通过。现在还没有什么需要重构的,所以你可以继续测试下一个场景:确保HTTP
错误在响应队列中被分派。
在之前的测试之后添加以下测试:
func test_getDogs_givenError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
let expectation = self.expectation(description: "Completion wasn't called")
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let error = NSError(domain: "com.DogPatchTests", code: 42)
mockTask.completionHandler(nil, response, error)
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
这个测试与之前的测试非常相似。然而,在when
部分,你向mockTask.completionHandler
传递一个错误。
构建并运行你的测试,令人惊讶的是,这个测试居然通过了!这是怎么回事?这到底是怎么回事?
在getDogs
中,你会看到对错误的检查和HTTP
状态代码是同一个guard
语句的一部分,它看起来像这样:
guard let reponse = response as? HTTPURLResponse,
reponse.statusCode == 200,
error == nil,
let data = data else {
结果是,这恰好已经把错误派发到了responseQueue
。
这是否意味着这个测试没有用?不,它仍然是有用的。如果你以后重构这段代码,并且这个检查没有像现在这样被合并在同一个防护中,你仍然想确保错误被派发到responseQueue
上。所以,让这个测试保持原样,继续重构。
这里确实有一些代码需要重构。
这两个测试之间有大量的重复代码。为了解决这个问题,在文件的顶部,紧接着whenGetDogs(...)
之后添加以下辅助方法:
func verifyGetDogsDispatchedToMain(data: Data? = nil,
statusCode: Int = 200,
error: Error? = nil,
line: UInt = #line) {
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
let expectation = self.expectation(description: "Completion wasn't called")
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil)
mockTask.completionHandler(data, response, error)
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread, line: line)
}
}
这个方法接受数据、statusCode
和错误的输入。这些将根据测试想要验证的实际行为而变化。它还接受一个行的输入,以确保XCTAssertTrue
将失败归于测试方法的行号,而不是这个辅助方法本身。
现在你可以使用这个辅助方法来摆脱重复的代码。将test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue
的内容替换为:
verifyGetDogsDispatchedToMain(statusCode: 500)
接下来,将test_getDogs_givenError_dispatchesToResponseQueue
的内容替换为:
// given
let error = NSError(domain: "com.DogPatchTests", code: 42)
// then
verifyGetDogsDispatchedToMain(error: error)
这就更可读、更紧凑了! 构建并运行单元测试,看到它们继续通过。
你需要覆盖的下一个测试场景是确保一个有效的响应分配到响应队列中。在上一个测试之后添加以下测试:
func test_getDogs_givenGoodResponse_dispatchesToResponseQueue() throws {
// given
let data = try Data.fromJSON(fileName: "GET_Dogs_Response")
// then
verifyGetDogsDispatchedToMain(data: data)
}
很好! 你很好地利用了你的辅助方法来编写紧凑的测试。建立并运行测试,你会看到这个测试失败了。
要解决这个问题,在DogPatchClient
的getDogs
中替换这一行:
completion(dogs, nil)
为以下代码:
guard let responseQueue = self.responseQueue else {
completion(dogs, nil)
return
}
responseQueue.async {
completion(dogs, nil)
}
类似于你处理错误的方式,这段代码检查是否有一个响应队列,如果有的话,将狗派发给它。
建立并运行测试。现在他们通过了。然而,现在DogPatchClient
中存在重复的逻辑,你需要消除它。要做到这一点,在getDogs
之后添加以下辅助方法:
private func dispatchResult<Type>(models: Type? = nil,
error: Error? = nil,
completion: @escaping (Type?, Error?) -> Void) {
guard let responseQueue = responseQueue else {
completion(models, error)
return
}
responseQueue.async {
completion(models, error)
}
}
你可以对任何模型使用这个方法,因为它使用一个通用的类型,并接受模型、错误和完成的输入。不管输入是什么,它总是检查是否有一个responseQueue
,并把完成分派给它。如果没有responeQueue
,它只是调用输入的完成。
你现在可以用这段代码来摆脱重复的代码了。首先,替换这段代码:
guard let responseQueue = self.responseQueue else {
completion(nil, error)
return
}
responseQueue.async {
completion(nil, error)
}
为以下代码:
self.dispatchResult(error: error, completion: completion)
接下来,替换这些线条:
guard let responseQueue = self.responseQueue else {
completion(dogs, nil)
return
}
responseQueue.async {
completion(dogs, nil)
}
为以下代码:
self.dispatchResult(models: dogs, completion: completion)
构建并运行你的测试,它们仍然通过。
最后,你需要验证,如果收到一个无效的JSON
响应,它也会被派发到响应队列中。添加下面的测试:
func test_getDogs_givenInvalidResponse_dispatchesToResponseQueue() throws {
// given
let data = try Data.fromJSON(fileName: "GET_Dogs_MissingValuesResponse")
// then
verifyGetDogsDispatchedToMain(data: data)
}
构建并运行你的测试,你会看到这个失败是预期的。要解决这个问题,请替换DogPatchClient
中getDogs
的这一行:
completion(nil, error)
具有以下特点:
self.dispatchResult(error: error, completion: completion)
构建并运行你的测试,并看到它们全部通过。你已经做了很好的重构工作,所以这里也没有什么需要重构的了。
猜猜还有什么?你刚刚完成了你的网络客户端的TDD
化! 干得好!
关键点¶
在本章中,你学到了如何为一个网络客户端做TDD
。下面是对你所学内容的回顾。
- 通过模拟
URLSession
和URLSessionTask
,避免在单元测试中进行真正的网络调用。 - 通过将
GET
请求分解成几个小任务,轻松完成TDD
。调用正确的URL
,处理HTTP
状态错误,处理有效和无效的响应。 - 对模拟
URLSessionTask
的调度行为到一个内部队列要小心。你可以通过在你的模拟上创建你自己的调度队列来解决这个问题。 - 派遣到一个响应队列,使消费者更容易使用你的网络客户端。
你离在屏幕上显示那些可爱的小狗们又近了一步 在下一章中,你将学习如何在视图控制器中使用网络客户端进行TDD
。