Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

10. ImageClient
Written by Joshua Greene

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

In the last chapter, you used DogPatchClient to download and display dogs. Each Dog has an imageURL, but you haven’t used it so far. While you could download images by making network requests directly within ListingsViewController, you wouldn’t be able to use that logic anywhere else.

Instead, you’ll do TDD to create an ImageClient for handling images. You can use ImageClient anywhere you need it in the app.

You’ll work through these steps in this chapter:

  • Set up the image client.
  • Create an image client protocol.
  • Download an image from a URL.
  • Cache tasks and images based on their URL.
  • Set an image from a URL on an image view.
  • Use the image client to display images.

Getting started

Feel free to use your project from the last chapter. If you want a fresh start, navigate to this chapter’s starter directory, open the DogPatch subdirectory and then open DogPatch.xcodeproj.

Your first step is going to be to get everything set up for your image client. Here’s how.

Setting up the image client

Another developer (ahem, you’re welcome) has already done TDD for ImageClient and its properties. To keep the focus on new concepts, this section will fast-track you through adding this code.

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

Creating an image client protocol

Similar to DogPatchClient, you’ll create a protocol for the ImageClient to enable you to mock and verify its use.

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

}
func test_imageService_declaresDownloadImage() {
  // given
  let url = URL(string: "https://example.com/image")!
  let service = sut as ImageService
  
  // then
  _ = service.downloadImage(fromURL: url) { _, _ in }
}
func downloadImage(
  fromURL url: URL,
  completion: @escaping (UIImage?, Error?) -> Void)
  -> URLSessionTaskProtocol
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 })
  }
}
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)
}
func setImage(on imageView: UIImageView,
              fromURL url: URL,
              withPlaceholder placeholder: UIImage?)
func setImage(on imageView: UIImageView,
              fromURL url: URL,
              withPlaceholder placeholder: UIImage?) {
  
}
var service: ImageService {
  return sut as ImageService
}
var url: URL!
url = URL(string: "https://example.com/image")!
url = nil

Downloading an image

You next need to implement downloadImage(fromURL:completion:).

func test_downloadImage_createsExpectedTask() {  
  // when
  let dataTask = sut.downloadImage(fromURL: url) { _, _ in }
    as? MockURLSessionTask
          
  // then
  XCTAssertEqual(dataTask?.url, url)
}
let task = session.makeDataTask(with: url) { 
  data, response, error in
          
}
return task
func test_downloadImage_callsResumeOnTask() {
  // when
  let dataTask =
    sut.downloadImage(fromURL: url) { _, _ in }
      as? MockURLSessionTask
  
  // then
  XCTAssertTrue(dataTask?.calledResume ?? false)
}
task.resume()
var receivedTask: MockURLSessionTask?
var receivedError: Error?
var receivedImage: UIImage?
receivedTask = nil
receivedError = nil
receivedImage = nil
// 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)
    }
}
// when
whenDownloadImage()
        
// then
XCTAssertEqual(receivedTask?.url, url)
// when
whenDownloadImage()

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

Handling the happy path

You’re now ready to handle the happy path, downloading an image successfully. Add this test next:

func test_downloadImage_givenImage_callsCompletionWithImage() {
  // given
  let expectedImage = UIImage(named: "happy_dog")!

  // when
  whenDownloadImage(image: expectedImage)

  // then
  XCTAssertEqual(expectedImage.pngData(),
                 receivedImage?.pngData())
}
if let data = data,      
    let image = UIImage(data: data) {
  completion(image, nil)
}

Handling the error path

You also need to handle the error case. Add this test right after the last one:

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?)
}
else {
  completion(nil, error)
}

Dispatching an image

Next, you need to ensure that completion dispatches to the responseQueue whenever your app successfully downloads an image. Add this test to verify this:

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)
}
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)
      }
    }
var expectedImage: UIImage!
expectedImage = nil
// MARK: - Given
func givenExpectedImage() {
  expectedImage = UIImage(named: "happy_dog")!
}
givenExpectedImage()

Dispatching an error

You also need to verify errors are dispatched to the responseQueue. Add this test right after the last one:

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)
}
completion(nil, error)
if let responseQueue = self.responseQueue {
  responseQueue.async { completion(nil, error) }
  
} else {
  completion(nil, error)
}
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) }
}
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)
// 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)
}
var expectedError: NSError!
expectedError = nil
func givenExpectedError() {
  expectedError = NSError(domain: "com.example",
  code: 42,
  userInfo: nil)
}
givenExpectedError()
// given
givenExpectedImage()

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

// then
verifyDownloadImageDispatched(error: expectedError)

Caching

Your ImageClient is really coming along, but it’s still missing a critical piece of functionality: Caching. Specifically, you need to cache images that the user has already downloaded.

func test_downloadImage_givenImage_cachesImage() {
  // given
  givenExpectedImage()
  
  // when
  whenDownloadImage(image: expectedImage)
  
  // then
  XCTAssertEqual(sut.cachedImageForURL[url]?.pngData(),
                 expectedImage.pngData())
}
self.cachedImageForURL[url] = image
func test_downloadImage_givenCachedImage_returnsNilDataTask() {
  // given
  givenExpectedImage()
  
  // when
  whenDownloadImage(image: expectedImage)
  whenDownloadImage(image: expectedImage)
  
  // then
  XCTAssertNil(receivedTask)
}
if let image = cachedImageForURL[url] {
  return nil
}    
func test_downloadImage_givenCachedImage_callsCompletionWithImage() {
  // given
  givenExpectedImage()
  
  // when
  whenDownloadImage(image: expectedImage)
  receivedImage = nil
  
  whenDownloadImage(image: expectedImage)
  
  // then
  XCTAssertEqual(expectedImage.pngData(),
                 receivedImage?.pngData())
}
completion(image, nil)

Setting an image view from a URL

Remember how you declared another method on ImageService, setImage(on imageView: fromURL url: withPlaceholder image:)?

Canceling a cached task

First, add this test to validate that you’ve canceled the existing task when setImageOnImageView is called, ignoring the compiler error for now:

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)

  // then
  XCTAssertTrue(task.calledCancel)
}
func cancel()
var calledCancel = false
func cancel() {
  calledCancel = true
}
cachedTaskForImageView[imageView]?.cancel()

Setting a placeholder image

Next, add this test to ensure the placeholder image is set on the 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())
}
imageView.image = placeholder
var imageView: UIImageView!
imageView = nil
imageView = UIImageView()

Caching the download image task

Next, you need to call downloadImage and cache the download task for the image view. Add this test right after the last one:

func test_setImageOnImageView_cachesTask() {
  // when
  sut.setImage(on: imageView,
               fromURL: url,
               withPlaceholder: nil)
  
  // then
  receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
  XCTAssertEqual(receivedTask?.url, url)
}
cachedTaskForImageView[imageView] =
  downloadImage(fromURL: url) { [weak self] image, error in
    guard let self = self else { return }

}

Removing the cached task

When downloadImage completes, you also need to remove task from the cache. Add this test for this:

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])
}
self.cachedTaskForImageView[imageView] = nil

Setting the image on image view

Lastly, you need to set the downloaded image on the image view. Add this test right after the last one:

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.image = image
func whenSetImage() {
  givenExpectedImage()
  sut.setImage(on: imageView,
               fromURL: url, 
               withPlaceholder: nil)
  receivedTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionTask
  receivedTask?.completionHandler(
    expectedImage.pngData(), nil, nil)
}
// when
whenSetImage()

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

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

Handling a download image error

In the case of an error, you’ll simply not set the image and instead will print a message to the console. To verify this happens, add the following test next:

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())
}
imageView.image = image
guard let image = image else {
  print("Set Image failed with error: " +
    String(describing: error))
  return
}
imageView.image = image

Using the image client

Great job implementing the ImageClient! You’re now ready to use it in ListingsViewController.

@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
  }
}
func test_imageClient_isImageService() {
  XCTAssertTrue((sut.imageClient as AnyObject) is ImageService)
}
var imageClient: ImageService =
    ImageClient(responseQueue: nil,
                session: URLSession())
func test_imageClient_setToSharedImageClient() {
  // given
  let expected = ImageClient.shared
  
  // then
  XCTAssertTrue((sut.imageClient as? ImageClient) === expected)
}
var imageClient: ImageService = ImageClient.shared
var mockImageClient: MockImageService!
mockImageClient = MockImageService()
sut.imageClient = mockImageClient
mockImageClient = nil
sut = ListingsViewController.instanceFromStoryboard()
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)
}
imageClient.setImage(
  on: cell.dogImageView,
  fromURL: URL(string: "http://example.com")!,
  withPlaceholder: nil)
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)
}
URL(string: "http://example.com")!
viewModel.imageURL
@discardableResult
func whenDequeueFirstListingsCell()
  -> ListingTableViewCell? {
    let indexPath = IndexPath(row: 0, section: 0)
    return sut.tableView(sut.tableView,
                         cellForRowAt: indexPath)
      as? ListingTableViewCell
}
// when
let cell = whenDequeueFirstListingsCell()
whenDequeueFirstListingsCell()
func test_tableViewCellForRowAt_callsImageClientWithPlaceholder() {
  // given
  givenMockViewModels()
  let placeholder = UIImage(named: "image_placeholder")!
  
  // when
  whenDequeueFirstListingsCell()
  
  // then
  XCTAssertEqual(
    mockImageClient.receivedPlaceholder.pngData(),
    placeholder.pngData())
}
withPlaceholder: nil
withPlaceholder: 
  UIImage(named: "image_placeholder")

Key points

In this chapter, you learned how to do TDD for an image client. Here are the key points:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now