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
}
}
下面是这个的作用:
- 你首先导入
UIKit
来访问UIImage
和UIImageView
,接下来你为ImageClient
创建一个新类。 - 接下来你声明一个共享的静态属性。你将在你的应用程序代码中使用它,但你将在单元测试中创建一次性的实例。这就像
DogPatchClient
一样。 - 然后你声明两个缓存属性,
cachedImageForURL
和cachedTaskForImageView
。你还为session
声明一个属性,你将用它来进行网络调用,为responseQueue
声明一个属性,你将用它来分配结果。 - 最后,你创建一个初始化器来设置每个属性。
你还需要为这个类添加测试。在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)
}
}
以下是其工作方式:
- 你同时导入
DogPatch
和XCTest
,并为ImageClientTests
创建一个测试类。 - 你声明两个实例属性:
mockSession
保持一个MockURLSession
,你将用它来代替真正的网络调用,sut
保持你正在测试的ImageClient
。 - 你在
setUp()
中设置每个实例属性,在tearDown()
中归零。 - 你创建测试,验证共享实例是否有预期的值。
- 最后,你要创建测试来验证初始化器是否按预期设置了属性。
构建并运行你的测试,以验证它们是否全部通过。
哇,你在很短的时间内涵盖了很多东西 虽然这段代码肯定是重要的,但你在以前的章节中学习了如何做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
创建为一个空方法,因为这是最简单的实现方式。
构建并运行测试,确认它们都能编译并通过。
有什么需要重构的吗?是的,你在最后两个测试中重复了service
和URL
。为了解决这个问题,在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
中删除service
和URL
的行。
最后,构建并运行测试以确保它们仍然通过。
下载图片¶
你接下来需要实现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
是否设置为true
;calledResume
是你在前一章中添加到MockURLSessionTask
的一个属性。
构建并运行以确保这个测试失败。为了使其通过,你实际上需要在任务上调用resume()
。在ImageClient
的downloadImage
中的返回语句之前添加以下内容:
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)
}
}
以下是其工作方式:
- 你为
whenDownloadImage
声明一个新方法。它需要两个输入,image
和error
。 - 你调用
sut.downloadImage
,将其返回值转换为MockURLSessionTask
并将其设置为receivedTask
。 - 你在
downloadImage
的完成中设置receivedImage
和receivedError
。 - 最后,你要检查
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?.calledResume
为true
。如果receiveTask
是nil
,那么你就默认为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
,然后断言expectedImage
和receivedImage
有相同的pngData()
。由于UIImage
使用对象平等,你不能直接比较图像。然而,你可以比较它们的底层数据来验证它们是否相同。
构建并运行测试以验证其失败。为了使它通过,你需要从传入的数据中实际创建一个图像,并使用它调用完成。
在ImageClient
的downloadImage
中的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
等于预期错误。
建立并运行这个测试以确认它失败了。为了让它通过,在ImageClient
的downloadImage
的完成闭合中添加以下代码,就在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
作为它的responseQueue
和mockSession
作为它的session
。最后,你创建了expectedImage
、receivedThread
和expectation
。 - 在
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)
}
}
你在这里做了两个改变:
- 你首先声明了
[weak self]
,然后在闭包中立即调用guard let self
。这就防止了由于捕获self
而产生的强引用循环。 - 如果你能够创建一个图像,你就检查是否设置了
responseQueue
,并向它分派完成。 - 如果
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) }
}
这个方法接受一个image
、error
和completion
。然后它验证是否设置了responseQueue
。如果没有,它直接调用完成。如果设置了,它就把完成工作分派给responseQueue
。
现在你可以用它来删除重复的应用程序逻辑。替换ImageClient
的downloadImage
中的这几行:
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
为零。
建立并运行这个测试以确保它失败。为了使其通过,将ImageClient
的downloadImage
的返回类型从URLSessionTaskProtocol
改为URLSessionTaskProtocol?
。
然而,这将导致编译器错误,因为ImageClient
不再符合ImageService
的要求。将ImageService
中downloadImage
的返回类型也改为URLSessionTaskProtocol?
。
然后,在ImageClient
的downloadImage
中添加这几行,就在该方法的开头大括号之后:
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
所做的,会发生什么?
在这种情况下,你需要做以下工作:
- 取消
UIImageView
的缓存任务,如果存在的话。 - 在
UIImageView
上设置一个占位符图像。 - 调用
downloadImage
并缓存UIImageView
的任务。 - 移除
UIImageView
的缓存任务。 - 在
UIImageView
上设置下载的图像。 - 处理如果收到错误会发生什么。
你现在有一个实现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
,在MockURLSessionTask
的calledResume
属性前添加这段代码:
var calledCancel = false
func cancel() {
calledCancel = true
}
你为calledCancel
添加了一个默认值为false
的属性,每当cancel
被调用时,你将其设置为true
。
建立并运行测试,以验证这个测试的失败。为了让它通过,在ImageClient
的setImage
中添加以下代码:
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
。然后用imageView
和expectedImage
调用setImage
,然后断言imageView.image
的数据等于expectedImage
的数据。
建立并运行测试,你会看到最后一个测试失败。为了让它通过,你要把imageView
上的图像设置为占位符。要做到这一点,在ImageClient
的setImage
中添加这个,就在关闭方法之前:
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
。
构建和运行测试来验证这是否失败。为了让它通过,在ImageClient
的setImage
中添加以下内容,就在该方法的封闭括号之前:
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
的数据。
建立并运行这个测试,你会看到它失败了。为了使它成功,在ImageClient
上setImage
方法中的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
中的setImage
的downloadImage
中的这一行:
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
}
}
以下是其工作方式:
- 你创建一个符合
ImageService
的新的MockImageService
。 - 你实现
downloadImage
,因为MockImageService
需要它,但你现在实际上不需要它。因此,你只需从它那里返回nil
。 - 你为
setImageCallCount
和接收值声明属性。 - 根据
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
。
在ListingsViewController
的var 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
来作为ImageService
的imageClient
。为了确保你不会意外地进行真实的网络调用,你将在setUp()
中创建它。
在这样做之前,你首先需要为mockImageClient
建立一个新的属性。在ListingsViewControllerTests
的var 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
上的receivedImageView
与cell
上的dogImageView
相匹配。
建立并运行这个测试来验证它的失败。为了使它通过,你需要将cell.dogImageView
实际传入imageClient.setImage
。在ListingsViewController
的listingCell(_:_:)
中添加这段代码,就在返回行之前:
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
如何在屏幕上加载和显示图像。
关键点¶
在本章中,你学到了如何为一个图像客户端做TDD
。以下是关键点:
- 你创建了一个服务协议,使嘲弄变得简单,就像网络客户端一样。
- 你创建了
downloadImage(...)
来处理一次性的图像下载请求,并对下载的图像进行缓存。 - 你创建了
setImage(...)
,使在UIImageView
上从URL设置图像更加方便。 - 记住,在你工作的过程中要进行重构。例如,你可以拉出辅助方法和属性,把异步的"下载"调用变成同步测试。
你已经为DogPatch
创建了核心功能,并在这一过程中学习了很多关于网络的知识!还有更多的功能你可以使用。你还可以添加更多的功能,包括:
- 认证
- 消息传递
- 评级
- 用户偏好
- 还有更多!
其中一些需要后端支持,但你也可以添加许多本地功能。当然,记得对网络和本地功能都要做TDD
! :]
你可以随心所欲地修补DogPatch
。当你准备好了,就进入下一节,学习遗留应用的TDD
。