跳转至

9:使用网络客户端

在上一章中,你发现ListingsViewController实际上并没有进行任何联网。相反,它在refreshData()中有一个//TODO注释。作为回应,你创建了DogPatchClient来处理网络逻辑。然而,你还没有使用它。

在本章中,你将更新ListingsViewController,以便在刷新时使用DogPatchClient具体来说,你将:

  • DogPatchClient上添加一个共享实例。
  • ListingsViewController上添加一个网络客户端属性。
  • 创建一个网络客户端协议。
  • 使用该协议创建一个mock的网络客户端。
  • mock的方式来存根和验证行为。

开始工作

请自由使用你在上一章中的项目。如果你想重新开始,可以导航到本章的起始目录,打开DogPatch子目录,然后打开DogPatch.xcodeproj

一旦你的项目准备好了,就该跳进去了,通过添加一个共享实例来设置DogPatchClient的联网。

创建一个共享实例

虽然你可以直接实例化DogPatchClient,但这有缺点:

  1. 你必须在实例化DogPatchClient的任何地方重复创建数据,包括基本URL、会话和响应队列。
  2. 你会并行地进行更多的网络调用。因此,你可能会使用更多的网络数据或损害电池寿命。

一个更好的选择是在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.sessionURLSession.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.networkClientDogPatchClient.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的网络客户端:

  1. 你可以通过子类化DogPatchClient并重写其每个方法来创建一个mock。这样做可以,但如果你忘记覆盖某个方法,你可能会意外地进行真正的网络调用。你也可能造成副作用,比如缓存假的网络响应。
  2. 类似于你mock URLSession的方式,你可以创建一个网络客户端协议,并直接使用它来代替DogPatchClient。因此,你会消除进行真正的网络调用或造成副作用的可能性。很好! 唯一的缺点是,你需要创建一个额外的协议,但这是非常快速和容易做到的。

一般来说,你应该倾向于使用协议创建一个mock的网络客户端,而不是子类化和覆盖。

你可能选择子类化和覆盖的一个原因是,你的应用程序与网络客户端或其相关类型紧密耦合。例如,如果你正在处理一个有很多未经测试的代码的遗留应用。即使如此,从长远来看,你也应该努力使用协议来取代这一点。

创建网络客户端协议

你应该把什么放在网络客户端协议中?任何调用者需要使用的方法和属性! 反过来,你就可以用你的mock来验证你对这些的调用是否正确。

好了,理论已经够多了!你已经准备好TDD了。你现在准备好TDD协议了。

像往常一样,你要先写一个测试。打开DogPatchClientTests.swift,在test_shared_setsBaseURL()之前添加以下方法,忽略由此产生的编译器错误:

func test_conformsTo_DogPatchService() {
    XCTAssertTrue((sut as AnyObject) is DogPatchService)
}

你把sut转成AnyObject,以防止出现编译器警告,然后你断言sutDogPathcService。然而,这导致了一个编译器错误,因为你还没有定义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 }
}

这个测试不会被编译,因为DogPatchServicegetDogs一无所知。为了解决这个问题,在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
    }
}

以下是你所做的事情:

  1. 你为MockDogPatchService创建一个符合DogPatchService的新类型。
  2. 你为baseURLgetDogsCallCountgetDogsCompletiongetDogsDataTask添加属性。你将使用它们来验证mock的调用是否符合预期,并返回存根的响应。
  3. 你实现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'

编译器期望networkClientDogPatchClient类型,但你试图将其设置为MockDogPatchClient,而MockDogPatchClient并不继承于DogPatchClient。为了解决这个错误,你需要明确地将networkClient的类型设置为DogPatchService

打开ListingsViewController.swift,替换这一行:

var networkClient = DogPatchClient.shared

为以下代码:

var networkClient: DogPatchService = DogPatchClient.shared

MockDogPatchServiceDogPatchClient都符合DogPatchService,所以这消除了编译器错误。然而,你会注意到test_networkClient_setToDogPatchClient不再被编译,因为Swift不能使用相同的运算符===来比较DogPatchClientDogPatchService。要解决这个问题,你需要把协议类型投给你想比较的对象类型。将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_setsRequesttest_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)
}

下面是这个测试的工作原理:

  1. given的部分中,你很好地利用了你的辅助方法来创建mockNetworkClientdogs
  2. when时,你首先调用sut.refreshData()来设置dataTask。然后,你将dogs传递给mockNetworkClient上的getDogsCompletion闭包。这将执行从ListingsViewController传入的闭包,并且它应该将dataTask设置为nil。
  3. then时,你断言sut.dataTask又被设置为nil

建立并运行这个测试,你会发现它失败了。当然,这是因为你没有在getDogs的完成闭包中真正地将dataTask设置为nil

为了使测试通过,请在ListingsViewControllerrefreshData中的完成闭包内添加这行:

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

下面是这个测试的工作原理:

  1. given的范围内,你使用你的辅助方法来创建mockNetworkClientdog,然后你通过将每个dog映射到DogViewModel来创建viewModels
  2. 对于when部分,你调用sut.refreshData()并执行getDogsCompletiongivendog
  3. 最后在then中,你断言sut.viewModels等于viewModels

构建并运行这个测试来验证它的失败。你需要在ListingsViewController上设置viewModels以使其通过。

ListingsViewControllerrefreshData()中,在dataTask = nil之后添加以下内容:

self.viewModels = dogs?.map { DogViewModel(dog: $0) } ?? []

这同样也是调用map来把dogs变成DogViewModel数组。如果出现错误,dogs可能是nil,所以你使用可选的unwrap操作符??并提供默认值作为一个空数组。

建立并运行你的测试,你会看到最后一个测试现在通过了。这里还有什么需要重构的吗?嗯,也许...

乍一看,test_refreshData_completionNilsDataTasktest_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)
}

这个测试有三个重要部分。

  1. 首先,你创建一个MockTableView来覆盖reloadData()。在这里面,你为calledReloadData更新一个布尔值。
  2. 接下来,你为mockTableView创建一个新的实例,并将其设置为sut.tableView,以确保它被使用。
  3. 最后,在你调用refreshData()并执行getDogsCompletion后,你断言mockTableView.calledReloadData为真。

建立并运行单元测试,你会发现这个测试失败了,因为你目前没有在tableView上调用reloadData。为了让这个测试通过,在ListingsViewControllerrefreshData()中设置viewModels之后,添加以下一行:

self.tableView.reloadData()

构建并再次运行测试,现在最后一项将通过。

好了,你终于准备好检查这个应用程序了。建立并运行它! 你会看到那些狗......没有出现?

相反,视图控制器显示了一个错误屏幕,如果你"下拉刷新",你会看到"加载指示灯"从未消失。

img

这是因为你实现tableView(_:numberOfRowsInSection:)的方式。它考虑了tableView是否在刷新。

为了解决这个问题,你需要在表视图的refreshControl上开始和结束刷新。在test_refreshData_givenDogsResponse_reloadsTableView()下面添加以下测试:

func test_refreshData_beginsRefreshing() {
    // given
    givenMockNetworkClient()
    // when
    sut.refreshData()
    // then
    XCTAssertTrue(sut.tableView.refreshControl!.isRefreshing)
}

该测试验证了在调用refreshData()后,refreshControlisRefreshing为真。建立并运行这个测试,它将会失败,因为你还没有开始刷新。

要解决这个问题,请在ListingsViewControllerrefreshData()中的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上的isRefreshingfalse。构建并运行这个测试,它将失败,因为你实际上还没有完成刷新。

为了让测试通过,在ListingsViewControllerrefreshData()中设置viewModels后添加以下一行:

self.tableView.refreshControl?.endRefreshing()

再次构建并运行你的测试,它们应该都能通过。

干得好! 你已经为整个refreshData()的实现做了TDD。构建并运行该应用,看看它的运行情况。

img

关键点

在本章中,你学会了如何使用网络客户端进行TDD。以下是你所涉及的关键点。

  • 你为网络客户端创建了一个共享实例,以避免在整个应用中出现多个实例。
  • 你避免在单元测试中直接使用真实的网络客户端,因为这需要一个互联网连接,这将导致他们更慢,使测试响应更难。你使用了一个mock的网络客户端来代替。
  • 你学到了为什么通过实现一个协议来创建一个mock的网络客户端,而不是通过子类和重写来创建。通过这样做,你避免了意外地进行真正的网络调用,以及诸如缓存等副作用。

你现在可以在屏幕上显示网络结果了 如果你也能看到小狗们的图像,而不是只有一个占位符的图像,那不是很好吗?你肯定会的! 在下一章中,你将学习如何创建一个图像客户端来帮助你做到这一点。