Home iOS & Swift Books iOS Test-Driven Development by Tutorials

10
ImageClient Written by Joshua Greene

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 that ImageClient anywhere you need it in the app.

As you work through this chapter, you’ll:

  • Set up the image client.
  • Create an image client protocol.
  • Download an image from a URL.
  • Cache data 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: .shared)
  
  // MARK: - Instance Properties
  // 3
  var cachedImageForURL: [URL: UIImage]
  var cachedTaskForImageView: [UIImageView: URLSessionDataTask]
  
  let responseQueue: DispatchQueue?
  let session: URLSession

  // MARK: - Object Lifecycle
  // 4
  init(responseQueue: DispatchQueue?,
       session: URLSession) {
  
    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() {
    XCTAssertEqual(ImageClient.shared.session, .shared)
  }
  
  // MARK: - Object Lifecycle - Tests
  // 5
  func test_init_setsCachedImageForURL() {
    XCTAssertEqual(sut.cachedImageForURL, [:])
  }
  
  func test_init_setsCachedTaskForImageView() {
    XCTAssertEqual(sut.cachedTaskForImageView, [:])
  }
    
  func test_init_setsResponseQueue() {
    XCTAssertEqual(sut.responseQueue, nil)
  }
  
  func test_init_setsSession() {
    XCTAssertEqual(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)
  -> URLSessionDataTask
func downloadImage(
  fromURL url: URL,
  completion: @escaping (UIImage?, Error?) -> Void)
    -> URLSessionDataTask {
  return URLSessionDataTask()
}
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_createsExpectedDataTask() {  
  // when
  let dataTask = sut.downloadImage(fromURL:url) { _, _ in }
    as? MockURLSessionDataTask
          
  // then
  XCTAssertEqual(dataTask?.url, url)
}
let dataTask =
  session.dataTask(with: url) { data, response, error in
    
}
return dataTask
func test_downloadImage_callsResumeOnDataTask() {
  // when
  let dataTask =
    sut.downloadImage(fromURL:url) { _, _ in }
      as? MockURLSessionDataTask
  
  // then
  XCTAssertTrue(dataTask?.calledResume ?? false)
}
dataTask.resume()
var receivedDataTask: MockURLSessionDataTask!
var receivedError: Error!
var receivedImage: UIImage!
receivedDataTask = nil
receivedError = nil
receivedImage = nil
// MARK: - When
// 1
func whenDownloadImage(
  image: UIImage? = nil, error: Error? = nil) {
  
  // 2
  receivedDataTask = sut.downloadImage(
      fromURL: url) { image, error in
            
        // 3
        self.receivedImage = image
        self.receivedError = error
    } as? MockURLSessionDataTask
    
    // 4
    if let receivedDataTask = receivedDataTask {
      if let image = image {
        receivedDataTask.completionHandler(
          image.pngData(), nil, nil)
        
      } else if let error = error {
        receivedDataTask.completionHandler(nil, nil, error)
      }
    }
}
// when
whenDownloadImage()
        
// then
XCTAssertEqual(receivedDataTask.url, url)
// when
whenDownloadImage()

// then
XCTAssertTrue(receivedDataTask.calledResume)

Handling the happy path

You’re now ready to handle the happy path: When your app downloads 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 case where there’s an error. 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(receivedError as NSError, expectedError)
}
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! MockURLSessionDataTask
  dataTask.completionHandler(expectedImage.pngData(), nil, nil)
  
  // then
  waitForExpectations(timeout: 0.2)
  XCTAssertTrue(receivedThread.isMainThread)
}
let dataTask =
  session.dataTask(with: url) { data, response, error in
    if let data = data, let image = UIImage(data: data) {
      completion(image, nil)
    }  
let dataTask =
  session.dataTask(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 a test to verify whether responseQueue receives an error. 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! MockURLSessionDataTask
  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! MockURLSessionDataTask
  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(receivedDataTask)
}
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(receivedImage.pngData(),
                 expectedImage.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 data task

First, add this test to validate that you’ve canceled the existing data task:

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

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

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 data task

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

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

}

Removing the cached data task

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

func test_setImageOnImageView_onCompletionRemovesCachedTask() {
  // given
  givenExpectedImage()
  
  
  // when
  sut.setImage(on: imageView, fromURL: url, withPlaceholder: nil)
  receivedDataTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionDataTask
  receivedDataTask.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)
  receivedDataTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionDataTask
  receivedDataTask.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)
  receivedDataTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionDataTask
  receivedDataTask.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)
  receivedDataTask = sut.cachedTaskForImageView[imageView]
    as? MockURLSessionDataTask
  receivedDataTask.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.

// 1
@testable import DogPatch
import UIKit

class MockImageService: ImageService {
  
  // 2
  func downloadImage(
  fromURL url: URL,
  completion: @escaping (UIImage?, Error?) -> Void)
    -> URLSessionDataTask? {
      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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as obfuscated text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.