跳转至

8:RESTful网络

在本章中,你将学习如何TDD一个RESTful网络客户端。具体来说,你将。

  • 设置网络客户端。
  • 确保正确的端点被调用。
  • 处理网络错误、有效响应和无效响应。
  • 将结果分配到响应队列中。

兴奋吧! TDD网络的神奇之处正在向你走来。

开始工作

导航到本章的起始目录。你会发现它有一个DogPatch的子目录,包含DogPatch.xcodeproj。在Xcode中打开这个项目文件并看一下。

你会看到有几个文件在等着你。本章的重要文件是:

  • Controllers/ListingsViewController.swift显示获取的狗或错误。
  • Models/Dog.swift是代表每个小狗的模型。
  • Networking暂时是一个空文件夹。你将在这里添加网络客户端和相关类型。

构建并运行该应用程序,下面的错误信息屏幕将迎接你:

img

如果你下拉刷新,活动指示器会产生动画,但永远不会完成。

打开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上声明会话,所以这不能编译。要解决这个问题,请在DogPatchClientbaseURL之后添加以下属性:

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.sessionsession是相等的。

建立并运行你的测试。正如预期的那样,这个测试失败了。为了使其通过,将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对象的列表。你将把它分解成几个小任务:

  1. Mocking URLSesssion
  2. 调用正确的URL
  3. 处理错误响应。
  4. 成功时对模型进行反序列化。
  5. 处理无效的响应。

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)
}

你断言传入的urltask.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_URLSessionTaskProtocoltest_URLSession_makeDataTask_createsTaskWithPassedInURL中的// givenlet 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

接下来,你需要为MockURLSessionMockURLSessionTask创建测试类型。在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() {
    }
}

以下是你的做法:

  1. 你声明MockURLSession符合URLSessionProtocol,并从makeDataTask创建一个MockURLSessionTask
  2. 你创建的MockURLSessionTask符合URLSessionTaskProtocol,声明urlcompletionHandler的属性并在其初始化器中设置这些属性。这样你就可以在你的测试中使用这些。
  3. 你现在将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.sessionmockSession是同一个实例。

这些改变解决了所有的编译器错误。构建并运行测试,并验证它们全部通过。

调用正确的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中,将DogPatchClientgetDogs(completion:)中的返回行替换为:

let task = session.makeDataTask(with: url) { data, response, error in
}

task.resume()

return task

处理错误响应

接下来,你需要处理错误响应。有两种情况表明发生了错误:

  1. 服务器返回一个除200以外的HTTP状态码。这个端点在成功时总是返回200。如果返回另一个状态码,说明请求失败。
  2. 请求可能永远不会到达服务器,可能超时或在网络层发生其他错误状况。在这种情况下,错误将被设置。

首先写一个测试,检查第一种情况。在最后一个测试之后添加以下测试:

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中,你断言完成处理程序被调用了,并且收到的dogerror的值为零。

建立并运行你的测试。正如预期的那样,这个测试将失败,因为完成处理程序没有被调用。

为了解决这个问题,在DogPatchClientgetDogssession.dataTask(with: url)的闭包中添加以下内容:

guard let response = response as? HTTPURLResponse,
      response.statusCode == 200 else {
    completion(nil, error)
    return
}

这个保护语句检查状态代码是否是预期的200结果,如果不是,则调用完成处理程序。

建立并运行你的测试。你的测试现在通过了。你看到有什么需要重构的吗?是的,getDogsURL在两个测试中是完全一样的。

为了消除这种重复,在DogPatchClientTestssut声明后添加以下计算属性:

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的范围内,你创建了一个statusCode200的响应和一个expectedError。你不太可能有一个200的成功响应代码和一个错误。然而,也许服务器的行为是不正确的,或者你在现实世界中碰到了某种边缘情况。嘿,服务器开发人员也不是完美的。不过从实际情况来看,这可以确保你之前对statusCode的保护在这种情况下不会被触发。
  • when中,你设置变量来检查是否调用了完成度以及收到了什么值。然后,你用之前的响应和预期错误调用mockTask上的completionHandler
  • then中,你断言完成任务被调用,收到的狗是空的,并且错误与你期望的一致。

建立并运行你的测试。calledCompletionunwrapping receivedError的断言失败了,这是有道理的,因为你还没有写这个代码。

你也可以暂时将receivedDogs的赋值改为空数组,以证明XCTAssertNil(receivedDogs)失败,但在继续之前一定要将这个属性设回nil

要使所有的断言都通过,请将DogPatchClientgetDogs中的整个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)
}

下面是它的工作原理:

  • 这个方法接受datastatusCode和错误的输入。为了方便起见,你也可以为每个人提供适当的默认值。它返回一个包含calledCompletion, dogserror值的元组。
  • 它使用getDogsURL和传入的statusCode创建响应。
  • 然后,它创建局部变量,在sut上调用getDogs,在mockTask上调用completionHandler,就像之前的测试一样。
  • 最后,该方法返回由calledCompletionreceivedDogsreceivedError的局部变量创建的元组。

你可以使用这个方法来删除测试中的重复代码。首先,将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.dogsresult.errornil

建立并运行你的测试,正如预期的那样,你会看到这个测试失败了。为了使其通过,将DogPatchClientgetDogs(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,并断言该完成被调用,返回的dognil,并且错误的域和代码与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在后台队列中调用它的关闭程序,这是有问题的,因为应用程序需要使用DogsError结果来执行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

接下来,你需要更新MockURLSessionMockURLSessionTask来调用调度队列上的完成处理程序。在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派发到派发队列的方式类似。

为了解决编译器的错误,在MockURLSessionmakeDataTask中用以下语句代替返回语句:

return MockURLSessionTask(
    completionHandler: completionHandler,
    url: url,
    queue: queue)

这段代码将队列传递给MockURLSessionTask的新初始化器。

建立并运行你的单元测试。因为没有一个测试依赖于完成处理程序被调用的队列,所以它们都会继续通过。

处理派发的情况

接下来,你需要验证completionHandler分派到responseQueue,在这些情况下应该发生:

  1. 一个HTTP状态代码表示一个失败的响应。
  2. 收到一个HTTP错误。
  3. 收到一个有效的JSON响应,并成功反序列化。
  4. 收到一个无效的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来等待期望的实现。在等待处理程序中,你断言该线程是主线程。

喔,这真是一个大手笔的测试 构建并运行单元测试,你会发现这个测试失败了,因为你目前没有向DogPatchClientresponseQueue分派。

要解决这个问题,首先在DogPatchClientgetDogs(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)
}

很好! 你很好地利用了你的辅助方法来编写紧凑的测试。建立并运行测试,你会看到这个测试失败了。

要解决这个问题,在DogPatchClientgetDogs中替换这一行:

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)
}

构建并运行你的测试,你会看到这个失败是预期的。要解决这个问题,请替换DogPatchClientgetDogs的这一行:

completion(nil, error)

具有以下特点:

self.dispatchResult(error: error, completion: completion)

构建并运行你的测试,并看到它们全部通过。你已经做了很好的重构工作,所以这里也没有什么需要重构的了。

猜猜还有什么?你刚刚完成了你的网络客户端的TDD化! 干得好!

关键点

在本章中,你学到了如何为一个网络客户端做TDD。下面是对你所学内容的回顾。

  • 通过模拟URLSessionURLSessionTask,避免在单元测试中进行真正的网络调用。
  • 通过将GET请求分解成几个小任务,轻松完成TDD。调用正确的URL,处理HTTP状态错误,处理有效和无效的响应。
  • 对模拟URLSessionTask的调度行为到一个内部队列要小心。你可以通过在你的模拟上创建你自己的调度队列来解决这个问题。
  • 派遣到一个响应队列,使消费者更容易使用你的网络客户端。

你离在屏幕上显示那些可爱的小狗们又近了一步 在下一章中,你将学习如何在视图控制器中使用网络客户端进行TDD