跳转至

10: ImageClient

在上一章中,你用DogPatchClient来下载和显示狗。每只狗都有一个imageURL,但你到目前为止还没有使用它。虽然你可以通过在ListingsViewController中直接进行网络请求来下载图片,但你无法在其他地方使用这个逻辑。

相反,你将做TDD来创建一个ImageClient来处理图片。你可以在应用中任何需要的地方使用ImageClient

你将在本章中完成这些步骤:

  • 设置图像客户端。
  • 创建一个图像客户端协议。
  • 从一个URL下载图像。
  • 根据URL来缓存任务和图像。
  • UIImageView上设置一个来自URL的图像。
  • 使用图像客户端来显示图像。

开始工作

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

你的第一步将是为你的图像客户端设置好一切。下面是方法。

设置图像客户端

另一个开发者(咳咳,欢迎你)已经为ImageClient和它的属性做了TDD。为了保持对新概念的关注,本节将快速跟踪你添加这些代码。

DogPatch/Networking下,创建一个名为ImageClient.swift的新Swift文件,并将其内容替换为以下内容:

// 1
import UIKit

class ImageClient {
    // MARK: - Static Properties
    // 2
    static let shared = ImageClient(responseQueue: .main,
                                    session: URLSession.shared)
    // MARK: - Instance Properties
    // 3
    var cachedImageForURL: [URL: UIImage]
    var cachedTaskForImageView: [UIImageView: URLSessionTaskProtocol]
    let responseQueue: DispatchQueue?
    let session: URLSessionProtocol

    // MARK: - Object Lifecycle
    // 4
    init(responseQueue: DispatchQueue?,
         session: URLSessionProtocol) {
        self.cachedImageForURL = [:]
        self.cachedTaskForImageView = [:]
        self.responseQueue = responseQueue
        self.session = session
    }
}

下面是这个的作用:

  1. 你首先导入UIKit来访问UIImageUIImageView,接下来你为ImageClient创建一个新类。
  2. 接下来你声明一个共享的静态属性。你将在你的应用程序代码中使用它,但你将在单元测试中创建一次性的实例。这就像DogPatchClient一样。
  3. 然后你声明两个缓存属性,cachedImageForURLcachedTaskForImageView。你还为session声明一个属性,你将用它来进行网络调用,为responseQueue声明一个属性,你将用它来分配结果。
  4. 最后,你创建一个初始化器来设置每个属性。

你还需要为这个类添加测试。在DogPatchTests/Cases/Networking下,创建一个新的Swift文件,名为ImageClientTests.swift,并将其内容改为如下:

// 1
@testable import DogPatch
import XCTest

class ImageClientTests: XCTestCase {
    // 2
    var mockSession: MockURLSession!
    var sut: ImageClient!
    // MARK: - Test Lifecycle
    // 3
    override func setUp() {
        super.setUp()
        mockSession = MockURLSession()
        sut = ImageClient(responseQueue: nil,
                          session: mockSession)
    }

    override func tearDown() {
        mockSession = nil
        sut = nil
        super.tearDown()
    }

    // MARK: - Static Properties - Tests
    // 4
    func test_shared_setsResponseQueue() {
        XCTAssertEqual(ImageClient.shared.responseQueue, .main)
    }

    func test_shared_setsSession() {
        XCTAssertTrue(ImageClient.shared.session ===
                      URLSession.shared)
    }

    // MARK: - Object Lifecycle - Tests
    // 5
    func test_init_setsCachedImageForURL() {
        XCTAssertTrue(sut.cachedImageForURL.isEmpty)
    }

    func test_init_setsCachedTaskForImageView() {
        XCTAssertTrue(sut.cachedTaskForImageView.isEmpty)
    }

    func test_init_setsResponseQueue() {
        XCTAssertTrue(sut.responseQueue === nil)
    }

    func test_init_setsSession() {
        XCTAssertTrue(sut.session === mockSession)
    }
}

以下是其工作方式:

  1. 你同时导入DogPatchXCTest,并为ImageClientTests创建一个测试类。
  2. 你声明两个实例属性:mockSession保持一个MockURLSession,你将用它来代替真正的网络调用,sut保持你正在测试的ImageClient
  3. 你在setUp()中设置每个实例属性,在tearDown()中归零。
  4. 你创建测试,验证共享实例是否有预期的值。
  5. 最后,你要创建测试来验证初始化器是否按预期设置了属性。

构建并运行你的测试,以验证它们是否全部通过。

哇,你在很短的时间内涵盖了很多东西 虽然这段代码肯定是重要的,但你在以前的章节中学习了如何做TDD。现在,你已经准备好潜入本章的新概念了!

创建一个图像客户端协议

DogPatchClient类似,你将为ImageClient创建一个协议,使你能够mock和验证它的使用。

一如既往,你首先需要写一个失败的测试。在ImageClientTests中添加以下内容,就在最后一个测试方法的后面:

// MARK: - ImageService - Tests
func test_conformsTo_ImageService() {
    XCTAssertTrue((sut as AnyObject) is ImageService)
}

你把sut转成AnyObject以防止编译器警告,然后断言这符合ImageService的要求。然而,由于你没有声明ImageService,所以这并不能编译。

为了解决这个问题,在ImageClient.swift的顶部,在导入部分之后添加以下内容:

protocol ImageService {
}

构建并运行测试,验证最后一个测试是否失败。

为了使其通过,在ImageClient的类关闭大括号后添加以下内容:

// MARK: - ImageService
extension ImageClient: ImageService {
}

构建并再次运行测试,以验证最后一个测试现在是否通过。没有什么需要重构的,所以你可以简单地继续。

接下来你需要一个测试来定义downloadImage方法的签名。打开ImageClientTests.swift,在最后一个测试后添加以下内容:

func test_imageService_declaresDownloadImage() {
    // given
    let url = URL(string: "https://example.com/image")!
    let service = sut as ImageService
    // then
    _ = service.downloadImage(fromURL: url) { _, _ in }
}

你通过将sut转成为ImageService来创建服务,然后调用service.downloadImage来验证该方法的存在。

由于你还没有声明这个方法,这导致了一个编译器错误。打开ImageClient.swift,在ImageService中添加以下代码来解决这个问题:

func downloadImage(fromURL url: URL,
                   completion: @escaping (UIImage?, Error?) -> Void)-> URLSessionTaskProtocol

你还需要让ImageClient实现这个方法,使其符合ImageService。将ImageClient上的扩展名替换为以下内容:

extension ImageClient: ImageService {
    func downloadImage(fromURL url: URL,
                       completion: @escaping (UIImage?, Error?) -> Void) -> URLSessionTaskProtocol {
        let url = URL(string: "https://example.com")!
        return session.makeDataTask(with: url, completionHandler:
                                        { _, _, _ in })
    }
}

你用假值调用session.makeDataTask来简单地使其编译。

构建并再次运行测试,以验证它们全部通过。最后,你还需要一个方法,从URL中设置图片到图片视图。打开ImageClientTests.swift,在最后添加这个测试:

func test_imageService_declaresSetImageOnImageView() {
    // given
    let service = sut as ImageService
    let imageView = UIImageView()
    let url = URL(string: "https://example.com/image")!
    let placeholder = UIImage(named: "image_placeholder")!
    // then
    service.setImage(on: imageView,
                     fromURL: url,
                     withPlaceholder: placeholder)
}

这个测试将验证一个新的方法,setImage(on:fromURL:withPlaceholder)是否存在。这不能编译,因为你没有在协议中声明它。要解决这个问题,请在ImageService中的downloadImage之后添加以下内容:

func setImage(on imageView: UIImageView,
              fromURL url: URL,
              withPlaceholder placeholder: UIImage?)

你还需要把这个方法添加到ImageClient中,以使它得到编译。在downloadImage之后,把这个方法添加到ImageClient中:

func setImage(on imageView: UIImageView,
              fromURL url: URL,
              withPlaceholder placeholder: UIImage?) {
}

你把setImage创建为一个空方法,因为这是最简单的实现方式。

构建并运行测试,确认它们都能编译并通过。

有什么需要重构的吗?是的,你在最后两个测试中重复了serviceURL。为了解决这个问题,在sut属性后面添加以下内容:

var service: ImageService {
    return sut as ImageService
}
var url: URL!

你还需要在每次测试运行前设置url。在setUp中添加这一行,就在设置sut之前:

url = URL(string: "https://example.com/image")!

在每次测试运行后,你需要重置URL。在tearDown中加入这一行,同样在设置sut之前:

url = nil

现在你可以在你的测试中使用这些属性。从test_imageService_declaresDownloadImage中删除整个given部分,从test_imageService_declaresSetImageOnImageView中删除serviceURL的行。

最后,构建并运行测试以确保它们仍然通过。

下载图片

你接下来需要实现downloadImage(fromURL:completion:)

在第一个测试中,你将验证会话使用传入的url创建一个URLSessionTaskProtocol。在ImageClientTests的最后一个测试后添加这段代码:

func test_downloadImage_createsExpectedTask() {
    // when
    let dataTask = sut.downloadImage(fromURL: url) { _, _ in }
    as? MockURLSessionTask
    // then
    XCTAssertEqual(dataTask?.url, url)
}

你把sut.downloadImage投给一个MockURLSessionTask,把它设为dataTask,然后断言dataTask?.url等于url

还记得你在这个测试的设置中是如何使用mockSession来创建ImageClient的吗?MockURLSession总是在其dataTask(with:completionHandler:)被调用时返回一个MockURLSessionTask

建立并运行这个测试,你会看到它失败了。这是因为当你在ImageClient上调用makeDataTask时,你并没有使用传入的url

要解决这个问题,请打开ImageClient.swift,用以下内容替换downloadImage的内容:

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

在这里,你调用makeDataTask并为闭合参数提供命名的参数;你很快就会用到这些参数。同样地,你将makeDataTask的返回值简单地命名为task

现在构建并运行测试,最后一个测试应该通过。

你还需要在任务上调用resume来启动它。打开ImageClientTests.swift,在类的末尾添加以下测试:

func test_downloadImage_callsResumeOnTask() {
    // when
    let dataTask = sut.downloadImage(fromURL: url) { _, _ in } as? MockURLSessionTask
    // then
    XCTAssertTrue(dataTask?.calledResume ?? false)
}

这一次,你调用downloadImage并验证calledResume是否设置为truecalledResume是你在前一章中添加到MockURLSessionTask的一个属性。

构建并运行以确保这个测试失败。为了使其通过,你实际上需要在任务上调用resume()。在ImageClientdownloadImage中的返回语句之前添加以下内容:

task.resume()

构建并再次运行测试,以验证最后一项是否通过。

你看到有什么需要重构的吗?是的,你在最后两个测试中重复了when的代码。你将会经常调用downloadImage,所以最好把它拉到一个辅助方法中。

在这之前,你首先需要添加一些属性。在ImageClientTests的其他属性之后添加以下内容:

var receivedTask: MockURLSessionTask?
var receivedError: Error?
var receivedImage: UIImage?

你还需要确保你在每次测试后释放这些东西。在设置sut之后,在tearDown()中添加这些行:

receivedTask = nil
receivedError = nil
receivedImage = nil

现在你可以编写辅助方法了。在tearDown()后面添加以下内容:

// MARK: - When
// 1
func whenDownloadImage(image: UIImage? = nil, error: Error? = nil) {

    // 2
    receivedTask = sut.downloadImage(
        fromURL: url) { image, error in

            // 3
            self.receivedImage = image
            self.receivedError = error
        } as? MockURLSessionTask

    // 4
    guard let receivedTask = receivedTask else {
        return
    }
    if let image = image {
        receivedTask.completionHandler(
            image.pngData(), nil, nil)

    } else if let error = error {
        receivedTask.completionHandler(nil, nil, error)
    }
}

以下是其工作方式:

  1. 你为whenDownloadImage声明一个新方法。它需要两个输入,imageerror
  2. 你调用sut.downloadImage,将其返回值转换为MockURLSessionTask并将其设置为receivedTask
  3. 你在downloadImage的完成中设置receivedImagereceivedError
  4. 最后,你要检查receivedTask是否被设置。如果是这样,你就检查图像是否被设置,然后用它来调用completionHandler。否则,你检查错误是否被设置,然后用它调用completionalHandler

现在你已经准备好使用这个辅助方法来重构你的测试! 将test_downloadImage_createsExpectedTask的内容替换为以下内容:

// when
whenDownloadImage()
// then
XCTAssertEqual(receivedTask?.url, url)

这样读起来就更顺畅了你只需调用whenDownloadImage,然后断言receivedTask?.url等于预期的url

接下来,把test_downloadImage_callsResumeOnTask的内容替换成这样:

// when
whenDownloadImage()
// then
XCTAssertTrue(receivedTask?.calledResume ?? false)

很好! 你再次重复使用whenDownloadImage() ,然后断言receivedTask?.calledResumetrue。如果receiveTasknil,那么你就默认为false,以导致测试失败。

处理快乐路径

现在你已经准备好处理快乐的路径,成功下载一个图片。接下来添加这个测试:

func test_downloadImage_givenImage_callsCompletionWithImage() {
    // given
    let expectedImage = UIImage(named: "happy_dog")!
    // when
    whenDownloadImage(image: expectedImage)
    // then
    XCTAssertEqual(expectedImage.pngData(),
                   receivedImage?.pngData())
}

在这里,你创建一个expectedImage,用它调用whenDownloadImage,然后断言expectedImagereceivedImage有相同的pngData()。由于UIImage使用对象平等,你不能直接比较图像。然而,你可以比较它们的底层数据来验证它们是否相同。

构建并运行测试以验证其失败。为了使它通过,你需要从传入的数据中实际创建一个图像,并使用它调用完成。

ImageClientdownloadImage中的session.dataTask闭包内添加以下内容:

if let data = data,
   let image = UIImage(data: data) {
    completion(image, nil)
}

在这里,你验证数据是否被设置,并尝试从它创建一个图像。如果这成功了,你就用它调用完成。

构建并运行测试,以验证测试现在通过。

处理错误路径

你还需要处理错误的情况。在最后一个测试之后马上添加这个测试:

func test_downloadImage_givenError_callsCompletionWithError() {
    // given
    let expectedError = NSError(domain: "com.example",
                                code: 42,
                                userInfo: nil)
    // when
    whenDownloadImage(error: expectedError)
    // then
    XCTAssertEqual(expectedError, receivedError as NSError?)
}

这与之前的测试类似,只是这次你在whenDownloadImage中传递一个预期错误,并断言receivedError等于预期错误。

建立并运行这个测试以确认它失败了。为了让它通过,在ImageClientdownloadImage的完成闭合中添加以下代码,就在if let data的大括号结束后:

else {
    completion(nil, error)
}

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

派遣图片

接下来,你需要确保每当你的应用程序成功下载一个图片时,completion就会向responseQueue分派。添加这个测试来验证这一点:

func test_downloadImage_givenImage_dispatchesToResponseQueue() {
    // given
    mockSession.givenDispatchQueue()
    sut = ImageClient(responseQueue: .main,
                      session: mockSession)
    let expectedImage = UIImage(named: "happy_dog")!
    var receivedThread: Thread!
    let expectation = self.expectation(
        description: "Completion wasn't called")
    // when
    let dataTask = sut.downloadImage(fromURL: url) { _, _ in
        receivedThread = Thread.current
        expectation.fulfill()
    } as! MockURLSessionTask
    dataTask.completionHandler(expectedImage.pngData(), nil, nil)
    // then
    waitForExpectations(timeout: 0.2)
    XCTAssertTrue(receivedThread.isMainThread)
}

下面是这个测试的工作方式:

  • given中,你首先调用mockSession.givenDispatchQueue()。这告诉mockSession创建一个MockURLSessionTask,在一个内部队列中分派它的completionHandler。然后,你创建sut,将.main作为它的responseQueuemockSession作为它的session。最后,你创建了expectedImagereceivedThreadexpectation
  • when中,你调用sut.downloadImage。在它的完成中,你设置了receivedThread并实现了期望值。然后你用image.pngData()执行dataTask.completionHandler
  • then中,你要等待,直到期望被满足。之后,你断言receivedThread.isMainThread

这类似于你如何验证DogPatchClient派发到它的响应队列。虽然你不能直接得到你的代码正在执行的调度队列,但你可以得到当前的线程并检查它是否是主线程。在苹果公司提供检查当前调度队列的方法之前,这对于测试来说是"足够好"的。

构建并运行这个测试来验证它的失败。要使它通过,请在ImageClient上的downloadImage中替换这段代码:

let task = session.makeDataTask(with: url) { data, response, error in
    if let data = data,
       let image = UIImage(data: data) {
        completion(image, nil)
    }

用下面的代码代替,注意不要改变这之前或之后的任何代码:

let task = session.makeDataTask(with: url) {
    // 1
    [weak self] data, response, error in
    guard let self = self else { return }
    if let data = data, let image = UIImage(data: data) {
        // 2
        if let responseQueue = self.responseQueue {
            responseQueue.async { completion(image, nil) }
            // 3
        } else {
            completion(image, nil)
        }
    }

你在这里做了两个改变:

  1. 你首先声明了[weak self],然后在闭包中立即调用guard let self。这就防止了由于捕获self而产生的强引用循环。
  2. 如果你能够创建一个图像,你就检查是否设置了responseQueue,并向它分派完成。
  3. 如果responseQueue没有设置,你直接调用完成。

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

对于重构步骤,你将把expectedImage移到一个属性中,以摆脱重复的代码。在其他属性之后添加这一行:

var expectedImage: UIImage!

你需要在tearDown()中释放它,所以在调用super.tearDown()之前添加这个:

expectedImage = nil

最后,在tearDown()后面添加这段代码:

// MARK: - Given
func givenExpectedImage() {
    expectedImage = UIImage(named: "happy_dog")!
}

很好! 现在你可以使用这个辅助方法来摆脱测试中的重复。将ImageClientTests中的let expectedImage = ...这几行替换为以下内容:

givenExpectedImage()

构建并运行测试,它们应该都能继续通过。

派发一个错误

你还需要验证错误是否被派发到响应队列中。在上一个测试之后添加这个测试:

func test_downloadImage_givenError_dispatchesToResponseQueue() {
    // given
    mockSession.givenDispatchQueue()
    sut = ImageClient(responseQueue: .main,
                      session: mockSession)

    let error = NSError(domain: "com.example",
                        code: 42,
                        userInfo: nil)
    var receivedThread: Thread!
    let expectation = self.expectation(
        description: "Completion wasn't called")

    // when
    let dataTask = sut.downloadImage(fromURL: url) { _, _ in
        receivedThread = Thread.current
        expectation.fulfill()
    } as! MockURLSessionTask
    dataTask.completionHandler(nil, nil, error)

    // then
    waitForExpectations(timeout: 0.2)
    XCTAssertTrue(receivedThread.isMainThread)
}

这个测试与成功案例非常相似。主要的区别是,你传递一个错误给dataTask.completionHandler而不是一个图像。

构建并运行测试,以验证这个失败。然后,在downloadImage onImageClient中替换这段代码:

completion(nil, error)

用这个代替:

if let responseQueue = self.responseQueue {
    responseQueue.async { completion(nil, error) }
} else {
    completion(nil, error)
}

构建并再次运行测试,以验证它们全部通过。

你在应用程序和测试代码中都重复了逻辑,所以你需要重构它!

你首先要更新应用程序的代码。具体来说,你需要一个新的方法来处理对响应队列的调度。在downloadImage之后添加以下内容:

private func dispatch(image: UIImage? = nil,
                      error: Error? = nil,
                      completion: @escaping (UIImage?, Error?) -> Void) {
    guard let responseQueue = responseQueue else {
        completion(image, error)
        return
    }
    responseQueue.async { completion(image, error) }
}

这个方法接受一个imageerrorcompletion。然后它验证是否设置了responseQueue。如果没有,它直接调用完成。如果设置了,它就把完成工作分派给responseQueue

现在你可以用它来删除重复的应用程序逻辑。替换ImageClientdownloadImage中的这几行:

if let responseQueue = self.responseQueue {
    responseQueue.async { completion(image, nil) }
} else {
    completion(image, nil)
}

替换为下面的代码:

self.dispatch(image: image, completion: completion)

然后,替换这些代码:

if let responseQueue = self.responseQueue {
    responseQueue.async { completion(nil, error) }
} else {
    completion(nil, error)
}

为以下这些:

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

构建并运行测试,以验证它们仍然全部通过。

接下来,你需要重构测试。特别是,有很多重复的代码用于验证你是否在向响应队列分派。

whenDownloadImage之后,在文件的顶部添加这个:

// MARK: - Then
func verifyDownloadImageDispatched(image: UIImage? = nil,
                                   error: Error? = nil,
                                   line: UInt = #line) {
    mockSession.givenDispatchQueue()
    sut = ImageClient(responseQueue: .main,
                      session: mockSession)

    var receivedThread: Thread!
    let expectation = self.expectation(
        description: "Completion wasn't called")

    // when
    let dataTask =
    sut.downloadImage(fromURL: url) { _, _ in
        receivedThread = Thread.current
        expectation.fulfill()
    } as! MockURLSessionTask
    dataTask.completionHandler(image?.pngData(), nil, error)

    // then
    waitForExpectations(timeout: 0.2)
    XCTAssertTrue(receivedThread.isMainThread, line: line)
}

这段代码与前两个单元测试验证receivedThread.isMainThread的方式非常相似。然而,它接受一个image、error和line为输入。它使用这些来调用dataTask.completionHandler,然后是XCTAssert

你还在几个地方重复了expectedError,所以你要把它移到一个属性中。在其他属性之后添加这一行:

var expectedError: NSError!

就像其他人一样,你也需要确保在每次测试运行后重置expectedError。在super.tearDown()之前的tearDown中加入这一行:

expectedError = nil

你还需要一个辅助方法来设置expectedError。在givenExpectedImag后面添加这个方法:

func givenExpectedError() {
    expectedError = NSError(domain: "com.example",
                            code: 42,
                            userInfo: nil)
}

现在你可以更新单元测试以利用这些方法。首先,把let expectedError = within test_downloadImage_givenError_callsCompletionWithError这一行替换成这样:

givenExpectedError()

接下来,将test_downloadImage_givenImage_dispatchesToResponseQueue的全部内容替换成这样:

// given
givenExpectedImage()
// then
verifyDownloadImageDispatched(image: expectedImage)

最后,将test_downloadImage_givenError_dispatchesToResponseQueue的内容替换成这样:

// given
givenExpectedError()
// then
verifyDownloadImageDispatched(error: expectedError)

非常好! 通过使用你的辅助方法,你已经大大简化了这些测试。构建并运行单元测试,以验证它们全部通过。

缓存

你的ImageClient已经很成熟了,但它仍然缺少一个关键的功能:缓存(Cache)。具体来说,你需要缓存用户已经下载过的图片。

在最后一个测试之后添加以下测试:

func test_downloadImage_givenImage_cachesImage() {
    // given
    givenExpectedImage()
    // when
    whenDownloadImage(image: expectedImage)
    // then
    XCTAssertEqual(sut.cachedImageForURL[url]?.pngData(),
                   expectedImage.pngData())
}

这个测试断言,预期的图像被缓存了。构建并运行测试来验证这个失败。为了使其通过,在ImageClient上的downloadImage中的if let data =一行之后添加以下内容:

self.cachedImageForURL[url] = image

构建并再次运行测试以确保其通过。

如果已经有一个缓存的图像,你不希望启动另一个任务。相反,你应该立即用它调用完成,并从downloadImage返回nil

接下来添加下面的测试:

func test_downloadImage_givenCachedImage_returnsNilDataTask() {
    // given
    givenExpectedImage()
    // when
    whenDownloadImage(image: expectedImage)
    whenDownloadImage(image: expectedImage)
    // then
    XCTAssertNil(receivedTask)
}

你把expectedImage传给whenDownloadImage,后者会缓存图片。然后第二次调用这个方法,并断言receivedTask为零。

建立并运行这个测试以确保它失败。为了使其通过,将ImageClientdownloadImage的返回类型从URLSessionTaskProtocol改为URLSessionTaskProtocol?

然而,这将导致编译器错误,因为ImageClient不再符合ImageService的要求。将ImageServicedownloadImage的返回类型也改为URLSessionTaskProtocol?

然后,在ImageClientdownloadImage中添加这几行,就在该方法的开头大括号之后:

if let image = cachedImageForURL[url] {
    return nil
}

你检查图片是否已经存在于cachedImageForURL中;如果是,你为任务返回nil

构建并运行单元测试,以验证它们现在全部通过。

如果有一个缓存的图片,你还需要立即调用完成。添加这个测试来验证这个行为的发生:

func test_downloadImage_givenCachedImage_callsCompletionWithImage() {
    // given
    givenExpectedImage()
    // when
    whenDownloadImage(image: expectedImage)
    receivedImage = nil
    whenDownloadImage(image: expectedImage)
    // then
    XCTAssertEqual(expectedImage.pngData(),
                   receivedImage?.pngData())
}

你用expectedImage调用whenDownloadImage,然后立即将receivedImage重置为nil。这样可以确保在第一次调用时没有设置receivedImage。你再次调用whenDownloadImage的预期图像,并断言receivedImage被设置。

构建和运行以确保这个测试失败。为了使其通过,在if后面添加以下内容let image = cachedImageForURL[url] {:

completion(image, nil)

如果在缓存中找到图像,你立即执行完成。

再次构建并运行测试,它们现在应该都通过了。

从URL设置UIImageView

还记得你是如何在ImageService上声明另一个方法,setImage(on imageView: fromURL url: withPlaceholder image:)吗?

你将实现这个方法作为一个方便的方法,用于从URL设置UIImageView上的图像。但是,等等!你不能直接调用downloadImage(fromURL:completion:)吗?

你可以,但你需要处理缓存逻辑。如果你已经为UIImageView下载了一个图像,会发生什么?例如,如果你在一个表格视图中显示图片视图...这正是ListingsViewController所做的,会发生什么?

在这种情况下,你需要做以下工作:

  1. 取消UIImageView的缓存任务,如果存在的话。
  2. UIImageView上设置一个占位符图像。
  3. 调用downloadImage并缓存UIImageView的任务。
  4. 移除UIImageView的缓存任务。
  5. UIImageView上设置下载的图像。
  6. 处理如果收到错误会发生什么。

你现在有一个实现setImage(on:fromURL:withPlaceholder:)的计划了!

取消一个缓存的任务

首先,添加这个测试来验证在调用setImageOnImageView时,你已经取消了现有的任务,暂时忽略编译器的错误:

func test_setImageOnImageView_cancelsExistingDataTask() {
    // given
    let task = MockURLSessionTask(
        completionHandler: { _, _, _ in },
        url: url,
        queue: nil)
    let imageView = UIImageView()
    sut.cachedTaskForImageView[imageView] = task
    // when
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: nil)
    XCTAssertTrue(task.calledCancel)
}

你创建了一个任务和imageView,并将其插入到sut.cachedTaskForImageView。然后你调用setImage,并断言task.calledCancel为真。

然而,你实际上并没有在URLSessionTaskProtocol上声明cancel(),所以这导致了编译器的错误。要解决这个问题,请打开URLSessionProtocol.swift,并在URLSessionTaskProtocol的开头大括号后添加这个:

func cancel()

你将cancel()声明为该协议的一个必要方法。因为URLSessionTask已经实现了cancel(),所以你不需要在它的扩展中做任何其他改动。然而,你确实需要更新MockURLSessionTask来实现这个新方法。

打开MockURLSession.swift,在MockURLSessionTaskcalledResume属性前添加这段代码:

var calledCancel = false

func cancel() {
    calledCancel = true
}

你为calledCancel添加了一个默认值为false的属性,每当cancel被调用时,你将其设置为true

建立并运行测试,以验证这个测试的失败。为了让它通过,在ImageClientsetImage中添加以下代码:

cachedTaskForImageView[imageView]?.cancel()

再次构建并运行测试,现在它们将通过。

设置一个占位符图片

接下来,添加这个测试以确保占位符图片被设置在imageView上:

func test_setImageOnImageView_setsPlaceholderOnImageView() {
    // given
    givenExpectedImage()
    let imageView = UIImageView()
    // when
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: expectedImage)
    // then
    XCTAssertEqual(imageView.image?.pngData(),
                   expectedImage.pngData())
}

你调用givenExpectedImage()来设置expectedImage,然后创建一个imageView。然后用imageViewexpectedImage调用setImage,然后断言imageView.image的数据等于expectedImage的数据。

建立并运行测试,你会看到最后一个测试失败。为了让它通过,你要把imageView上的图像设置为占位符。要做到这一点,在ImageClientsetImage中添加这个,就在关闭方法之前:

imageView.image = placeholder

构建并再次运行测试,以确保它们全部通过。

有什么需要重构的吗?是的,你在两个测试中重复了imageView。为了消除重复,在ImageClientTests中的其他属性之后添加这个属性:

var imageView: UIImageView!

然后,在tearDown中添加这一行,在每次运行后,在调用super.tearDown()之前重置imageView

imageView = nil

虽然你可以为givenImageView()创建一个辅助方法,但你将在多个测试中使用imageView。因此,你要在每次测试运行前设置它。在setUp()中添加这一行,就在设置sut之前:

imageView = UIImageView()

缓存下载图片的任务

接下来,你需要调用downloadImage并缓存图片视图的下载任务。在上一个测试之后添加这个测试:

func test_setImageOnImageView_cachesTask() {
    // when
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: nil)
    // then
    receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
    XCTAssertEqual(receivedTask?.url, url)
}

你调用sut.setImage,在cachedTaskForImageView中使用imageView解包receivedTask,然后断言dataTask.url等于url

构建和运行测试来验证这是否失败。为了让它通过,在ImageClientsetImage中添加以下内容,就在该方法的封闭括号之前:

cachedTaskForImageView[imageView] = downloadImage(fromURL: url) { [weak self] image, error in
    guard let self = self else { return }
}

构建并运行测试,验证最后一个测试现在是否通过。

删除缓存的任务

downloadImage完成后,你还需要从缓存中删除任务。为此添加这个测试:

func test_setImageOnImageView_onCompletionRemovesCachedTask() {
    // given
    givenExpectedImage()
    // when
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: nil)
    receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
    receivedTask?.completionHandler(
        expectedImage.pngData(), nil, nil)
    // then
    XCTAssertNil(sut.cachedTaskForImageView[imageView])
}

你调用setImage并解除对receivedTask的包装。然后在receiveTask上调用completionalHandler,最后断言该任务已从缓存中删除。

构建并运行这个测试以验证它是否通过。为了使其通过,在setImage中添加这一行,紧接着你之前添加的防护语句:

self.cachedTaskForImageView[imageView] = nil

这将从cachedTaskForImageView中删除imageView的缓存任务。

再次建立并运行测试,以验证它们都通过了。

UIImageView上设置图像

最后,你需要在UIImageView中设置下载的图像。在最后一个测试之后添加这个测试:

func test_setImageOnImageView_onCompletionSetsImage() {
    // given
    givenExpectedImage()
    // when
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: nil)
    receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
    receivedTask?.completionHandler(
        expectedImage.pngData(), nil, nil)
    // then
    XCTAssertEqual(imageView.image?.pngData(),
                   expectedImage.pngData())
}

这个测试和上一个非常相似;不同的是,你断言imageView的图像数据等于expectedImage的数据。

建立并运行这个测试,你会看到它失败了。为了使它成功,在ImageClientsetImage方法中的downloadImage闭包里,在你之前添加的设置imageView上的图像的那一行之后添加这段代码:

```swift 有那么快, imageView.image = image

构建并运行以验证测试现在通过了。然而,你现在有重复的代码,需要重构。

在`whenDownloadImage`之后添加以下代码:

```swift
func whenSetImage() {
    givenExpectedImage()
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: nil)
    receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
    receivedTask?.completionHandler(
        expectedImage.pngData(), nil, nil)
}

你已经把普通的代码移到了这个方法中。因此,你需要把test_setImageOnImageView_onCompletionRemovesCachedTask的内容替换为以下内容:

// when
whenSetImage()
// then
XCTAssertNil(sut.cachedTaskForImageView[imageView])

然后,将test_setImageOnImageView_onCompletionSetsImage的内容替换成这样:

// when
whenSetImage()
// then
XCTAssertEqual(imageView.image?.pngData(),
               expectedImage.pngData())

很好! 这使这两个测试变得更加简单。

处理下载图片的错误

在出现错误的情况下,你会简单地不设置图片,而是向控制台打印一条信息。为了验证这种情况的发生,接下来添加以下测试:

func test_setImageOnImageView_givenError_doesnSetImage() {
    // given
    givenExpectedImage()
    givenExpectedError()
    // when
    sut.setImage(on: imageView,
                 fromURL: url,
                 withPlaceholder: expectedImage)
    receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
    receivedTask?.completionHandler(nil, nil, expectedError)
    // then
    XCTAssertEqual(imageView.image?.pngData(),
                   expectedImage.pngData())
}

这里是如何工作的:

  • given中,你调用givenExpectedImage()来创建expectedImage,调用givenExpectedError来创建expectedError
  • when中,你调用setImage,解开任务,用expectedError执行其completionHandler。因此,这将在imageView上设置expectedImage,因为它被作为占位符图片传递。
  • then中,你断言imageView上的图像仍然被设置为预期图像。

建立并运行这个测试来验证它是否失败。要使它通过,请替换ImageClient中的setImagedownloadImage中的这一行:

imageView.image = image

用这个代替:

guard let image = image else {
    print("Set Image failed with error: " +
          String(describing: error))
    return
}

imageView.image = image

你要保证图像实际上是在这里设置的。如果不是,你将错误打印到控制台。如果是,你就在imageView上设置它。

建立并运行测试,它们都会通过。

使用图像客户端

实现ImageClient的工作做得很好! 你现在可以在ListingsViewController中使用它了。

在这之前,你需要创建一个MockNetworkClient。在DogPatchTests/Test Types/Mocks中创建一个新的Swift文件,名为MockImageService.swift。将其内容替换为以下内容:

@testable import DogPatch
import UIKit
// 1
class MockImageService: ImageService {
    // 2
    func downloadImage(fromURL url: URL,
                       completion: @escaping (UIImage?, Error?) -> Void) -> URLSessionTaskProtocol? {
        return nil
    }
    // 3
    var setImageCallCount = 0
    var receivedImageView: UIImageView!
    var receivedURL: URL!
    var receivedPlaceholder: UIImage!
    // 4
    func setImage(on imageView: UIImageView,
                  fromURL url: URL,
                  withPlaceholder placeholder: UIImage?) {
        setImageCallCount += 1
        receivedImageView = imageView
        receivedURL = url
        receivedPlaceholder = placeholder
    }
}

以下是其工作方式:

  1. 你创建一个符合ImageService的新的MockImageService
  2. 你实现downloadImage,因为MockImageService需要它,但你现在实际上不需要它。因此,你只需从它那里返回nil
  3. 你为setImageCallCount和接收值声明属性。
  4. 根据MockImageService要求的其他方法,实现setImage。在这里,你增加setImageCallCount并设置每个接收的属性。

现在你可以好好利用这个模拟了。打开ListingsViewControllerTests.swift,在// MARK: - View Life Cycle - Tests之前添加以下内容:

func test_imageClient_isImageService() {
    XCTAssertTrue((sut.imageClient as AnyObject) is ImageService)
}

你在这里把sut.imageClient铸成AnyObject以消除警告,然后断言它是一个ImageService。然而,这个测试并没有被编译,因为你还没有在ListingsViewController上声明imageClient

ListingsViewControllervar networkClient后面添加以下属性:

var imageClient: ImageService = ImageClient(responseQueue: nil,
                                            session: URLSession())

建立并运行测试,现在最后一个测试应该成功了。

接下来,在test_imageClient_isImageService之后添加这个测试,以确保imageClient实际上被设置为ImageClient.shared

func test_imageClient_setToSharedImageClient() {
    // given
    let expected = ImageClient.shared
    // then
    XCTAssertTrue((sut.imageClient as? ImageClient) === expected)
}

建立并运行这个测试,以确保它失败。为了使其通过,请将ListingsViewController中的var imageClient声明更新为以下内容:

var imageClient: ImageService = ImageClient.shared

构建并再次运行测试以确保其通过。

接下来你需要一个MockImageClient来作为ImageServiceimageClient。为了确保你不会意外地进行真实的网络调用,你将在setUp()中创建它。

在这样做之前,你首先需要为mockImageClient建立一个新的属性。在ListingsViewControllerTestsvar mockNetworkClient之前添加这个属性:

var mockImageClient: MockImageService!

然后在setUp()中设置sut之后,添加以下内容:

mockImageClient = MockImageService()
sut.imageClient = mockImageClient

tearDown()中添加以下一行代码,将mockImageClient设置为nil

mockImageClient = nil

然而,如果你构建并运行测试,你会发现test_imageClient_setToSharedImageClient现在失败了!这是因为你在setUp中把sut.imageClient设置为mockImageClient,因此它永远不会等于ImageClient.shared。这是因为你在setUp中把sut.imageClient设置为mockImageClient,因此,它永远不会等于ImageClient.shared

幸运的是,这个问题很容易解决。在test_imageClient_setToSharedImageClient中,在// given之后添加以下一行:

sut = ListingsViewController.instanceFromStoryboard()

再次构建并运行测试,现在它们都会通过。

你现在可以在单元测试中使用mockImageClient了。在最后一个测试之后添加这个测试:

func test_tableViewCellForRowAt_callsImageClientSetImageWithDogImageView() {
    // given
    givenMockViewModels()
    // when
    let indexPath = IndexPath(row: 0, section: 0)
    let cell = sut.tableView(sut.tableView,
                             cellForRowAt: indexPath)
    as? ListingTableViewCell
    // then
    XCTAssertEqual(mockImageClient.receivedImageView,
                   cell?.dogImageView)
}
  • given中,你调用givenMockViewModels()来创建一个视图模型数组,并将其设置在sut上。
  • when中,你为第一个IndexPath的单元格取消了queue,并将其转换为ListingTableViewCell
  • then中,你断言mockImageClient上的receivedImageViewcell上的dogImageView相匹配。

建立并运行这个测试来验证它的失败。为了使它通过,你需要将cell.dogImageView实际传入imageClient.setImage。在ListingsViewControllerlistingCell(_:_:)中添加这段代码,就在返回行之前:

imageClient.setImage(
    on: cell.dogImageView,
    fromURL: URL(string: "http://example.com")!,
    withPlaceholder: nil)

再次构建并运行测试,现在最后一个测试将通过。你还需要确保你把正确的URL传给了

imageClient.setImage。在最后一个测试之后添加这个测试:

func test_tableViewCellForRowAt_callsImageClientSetImageWithURL() {
    // given
    givenMockViewModels()
    let viewModel = sut.viewModels.first!
    // when
    let indexPath = IndexPath(row: 0, section: 0)
    _ = sut.tableView(sut.tableView, cellForRowAt: indexPath)
    // then
    XCTAssertEqual(mockImageClient.receivedURL,
                   viewModel.imageURL)
}

与上一个测试类似,你首先调用givenMockViewModels()来设置sut.viewModels,然后从中获取第一个。然后调用sut.tableView(_:cellForRowAt:)来触发对第一个单元格的配置,然后断言mockImageClient.receivedURL等于viewModel.imageURL

建立并运行测试,你会发现这个测试失败了。为了让它成功,在ListingsViewController中替换这个参数:

URL(string: "http://example.com")!

用这个代码:

viewModel.imageURL

这将把viewModel中的imageURL传递给imageClient.setImage调用。

构建并运行测试,以确保它们全部通过。

对于重构步骤,你现在在最后两个测试中有类似的代码。为了摆脱重复,在whenDequeueTableViewCells之后添加以下方法:

@discardableResult
func whenDequeueFirstListingsCell() -> ListingTableViewCell? {
    let indexPath = IndexPath(row: 0, section: 0)
    return sut.tableView(sut.tableView,
                         cellForRowAt: indexPath)
    as? ListingTableViewCell
}

你在这里取消了第一个表视图单元格,然后把它作为ListingTableViewCell

接下来,将test_tableViewCellForRowAt_callsImageClientSetImageWithDogImageView中的when部分替换为以下内容:

 // when
let cell = whenDequeueFirstListingsCell()

然后,将test_tableViewCellForRowAt_callsImageClientSetImageWithURL中的when部分替换成这样:

whenDequeueFirstListingsCell()

很好,这就解决了重复代码的问题。

最后,你需要一个测试来确认你是否将占位符图片传入setImage。在最后一个测试之后添加这个测试:

func test_tableViewCellForRowAt_callsImageClientWithPlaceholder() {
    // given
    givenMockViewModels()
    let placeholder = UIImage(named: "image_placeholder")!
    // when
    whenDequeueFirstListingsCell()
    // then
    XCTAssertEqual(
        mockImageClient.receivedPlaceholder.pngData(),
        placeholder.pngData())
}

这个测试与之前的测试类似。然而,你在这里声明一个预期的占位符,并断言mockImageClient.receivedPlaceholder上的基础数据与之相同。

构建并运行测试,确认这个测试失败。为了让它通过,在ListingsViewController中替换以下内容:

withPlaceholder: nil

用这个代替:

withPlaceholder: UIImage(named: "image_placeholder")

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

现在是有趣的部分--你已经做了TDD来创建甚至使用ImageClient,但你还没有看到你的努力工作得到回报。你终于准备好使用它了!

构建并运行应用程序,检查并查看ImageClient如何在屏幕上加载和显示图像。

img

关键点

在本章中,你学到了如何为一个图像客户端做TDD。以下是关键点:

  • 你创建了一个服务协议,使嘲弄变得简单,就像网络客户端一样。
  • 你创建了downloadImage(...)来处理一次性的图像下载请求,并对下载的图像进行缓存。
  • 你创建了setImage(...),使在UIImageView上从URL设置图像更加方便。
  • 记住,在你工作的过程中要进行重构。例如,你可以拉出辅助方法和属性,把异步的"下载"调用变成同步测试。

你已经为DogPatch创建了核心功能,并在这一过程中学习了很多关于网络的知识!还有更多的功能你可以使用。你还可以添加更多的功能,包括:

  • 认证
  • 消息传递
  • 评级
  • 用户偏好
  • 还有更多!

其中一些需要后端支持,但你也可以添加许多本地功能。当然,记得对网络和本地功能都要做TDD! :]

你可以随心所欲地修补DogPatch。当你准备好了,就进入下一节,学习遗留应用的TDD