Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

First Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

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.

Under DogPatch/Networking, create a new Swift File called ImageClient.swift and replace its contents with the following:

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

Here’s what this does:

  1. You first import UIKit to access UIImage and UIImageView, then you create a new class for ImageClient.
  2. You next declare a static property for shared. You’ll use this in your app code, but you’ll create one-off instances in your unit tests. This is just like DogPatchClient.
  3. You then declare two cache properties, cachedImageForURL and cachedTaskForImageView. You also declare one property for session, which you’ll use to make the networking calls, and one for responseQueue, which you’ll use to dispatch the results.
  4. Last, you create an initializer that sets each property.

You also need to add the tests for this class. Under DogPatchTests/Cases/Networking, create a new Swift File called ImageClientTests.swift and replace its contents with the following:

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

Here’s how this works:

  1. You import both DogPatch and XCTest and then create a test class for ImageClientTests.
  2. You declare two instance properties: mockSession keeps hold of a MockURLSession, which you’ll use instead of making real networking calls and sut keeps hold of the ImageClient you’re testing.
  3. You set each instance property within setUp() and nil them within tearDown().
  4. You create tests that validate that the shared instance has expected values.
  5. Lastly, you add tests to validate that the initializer sets properties like you expected.

There’s a bit of final clean up you need to do. Under DogPatchTests/Test Types/Mocks, create a new Swift File called MockSession.swift and another file called MockURLSessionDataTask.swift.

Open DogPatchClientTests.swift and cut (or copy and delete) the entire MockURLSession class, then paste it into MockSession.swift, right after import Foundation. Likewise, cut and paste the entire MockURLSessionDataTask class into MockURLSessionDataTask.swift.

This makes it clear that MockURLSessionDataTask and MockURLSessionDataTask are separate types from DogPatchClientTests. It’s best to move these, now that you’ll be using these mocks in more than one test case.

Build and run your tests to verify that they all pass.

Wow, you covered a lot in short amount of time! While this code is definitely important, you learned how to do TDD for this in previous chapters. You’re now ready to dive into new concepts for this chapter!

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.

As always, you first need to write a failing test. Add the following to ImageClientTests, right after the last test method:

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

You cast sut as AnyObject to prevent a compiler warning and then assert this conforms to ImageService. However, this doesn’t compile because you haven’t declared ImageService.

To fix this, add the following to the top of ImageClient.swift after the imports:

protocol ImageService {
  
}

Build and run the tests to validate the last one fails.

To make it pass, add the following after the class closing curly brace for ImageClient:

// MARK: - ImageService
extension ImageClient: ImageService {

}

Build and run the tests again to verify the last one now passes. There’s nothing to refactor, so you can simply continue.

You next need a test to define the downloadImage method signature. Add this right after the last test:

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

You create service by casting sut as ImageService and then call service.downloadImage to verify the method exists.

Since you’ve yet to declare this method, this causes a compiler error. Add the following code within ImageService to fix this:

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

You also need to make ImageClient implement this method to make it conform to ImageService. Add the following inside the extension on ImageClient:

func downloadImage(
  fromURL url: URL,
  completion: @escaping (UIImage?, Error?) -> Void)
    -> URLSessionDataTask {
  return URLSessionDataTask()
}

You return a new URLSessionDataTask() because this is the simplest way to make it compile.

Build and run the tests again to verify they all pass. Lastly, you need one more method, to set an image onto an image view from a URL. Add this test next:

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

This test will verify that a new method, setImage(on:fromURL:withPlaceholder), exists. This doesn’t compile because you haven’t declared it in the protocol. To fix it, add the following to ImageService after downloadImage:

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

You also need to add this method to ImageClient to make it compile. Add this to ImageClient after downloadImage:

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

You create setImage as an empty method because that’s the easiest way to implement it.

Build and run the tests to confirm they all compile and pass.

Is there anything to refactor? Yes, you duplicated service and url within the last two tests. To fix this, add the following after the sut property:

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

You also need to set url before each test run. Add this line to setUp, right before setting sut:

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

After each test run, you need to reset url. Add this line to tearDown, again before setting sut:

url = nil

You can now use these properties within your tests. Delete the entire given section from test_imageService_declaresDownloadImage and delete the lines for service and url from test_imageService_declaresSetImageOnImageView.

Finally, build and run the tests to ensure all tests still pass.

Downloading an image

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

For the first test, you’ll validate that session creates the returned URLSessionDataTask using the passed-in url. Add this code after the last test in ImageClientTests:

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

You cast sut.downloadImage to a MockURLSessionDataTask, set this to dataTask and then assert dataTask.url equals url.

Remember how you used mockSession to create ImageClient in this test’s setup? MockURLSession always returns a MockURLSessionDataTask whenever its dataTask(with:,completionHandler:) is called.

Build and run this test and you’ll see it fails. This is because the cast to MockURLSessionDataTask fails and, consequently, dataTask?.url is nil. To fix this, you need to update ImageClient to use its session to create and return the data task.

Replace the contents of downloadImage within ImageClient with the following:

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

Build and run the tests now, and the last one should pass.

You also need to call resume on the dataTask to start it. Add this test for such:

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

This time, you call downloadTask and verify calledResume is set to true; calledResume is a property you added to MockURLSessionDataTask in a previous chapter.

Build and run to ensure this test fails. To make it pass, you actually need to call resume(). Add the following right before the return statement within downloadImages on ImageClient:

dataTask.resume()

Build and run the tests again to verify the last one passes.

Do you see anything to refactor? Yep, you’ve duplicated the when code in the last two tests. You’re going to call downloadImage a lot, so it’s best to pull this into a helper method.

Before you do, you first need to add a few properties. Add the following right after the other properties on ImageClientTests:

var receivedDataTask: MockURLSessionDataTask!
var receivedError: Error!
var receivedImage: UIImage!

You also need to ensure you release these after each test. Add these lines within tearDown() right after setting sut:

receivedDataTask = nil
receivedError = nil
receivedImage = nil

You can now write the helper method. Add the following right after tearDown():

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

Here’s how this works:

  1. You declare a new method for whenDownloadImage. It takes two inputs, image and error.
  2. You call sut.downloadImage, cast its return value to MockURLSessionDataTask and set this to receivedDataTask.
  3. You set receivedImage and receivedError in the completion for downloadImage.
  4. Lastly, you check if receivedDataTask is set. If so, you then check if image is set and call completionHandler with it. If image isn’t set, you check if error is set and call completionHandler with it instead.

You’re now ready to use this helper method to refactor your tests! Replace the contents of test_downloadImage_createsExpectedDataTask with the following:

// when
whenDownloadImage()
        
// then
XCTAssertEqual(receivedDataTask.url, url)

This is much nicer to read! You simply call whenDownloadImage and then assert receivedDataTask.url equals the expected url.

Next, replace the contents of test_downloadImage_callsResumeOnDataTask with this:

// when
whenDownloadImage()

// then
XCTAssertTrue(receivedDataTask.calledResume)

Nice! You again reuse whenDownloadImage() and then assert receivedDataTask.calledResume is true.

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

Here, you create an expectedImage, call whenDownloadImage with it and then assert that expectedImage and receivedImage have the same pngData(). Since UIImage uses object equality, you cannot compare images directly. However, you can compare their underlying data to verify they’re the same.

Build and run the test to verify it fails. To make it pass, you need to actually create an image from the passed-in data and call the completion with it.

Add the following inside the session.dataTask closure within downloadImage on ImageClient:

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

Here, you verify that data is set and try to create an image from it. If this succeeds, you call completion with it.

Build and run the tests to verify the test now passes.

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

This is similar to the previous test, except this time you’re passing an expectedError into whenDownloadImage and asserting receivedError equals expectedError.

Build and run this test to confirm it fails. To get it to pass, add the following code inside the completion closure within downloadImage on ImageClient, right after the closing curly brace for if let data:

else {
  completion(nil, error)
}

Build and run the tests again, and they should all pass now.

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

Here’s how this test works:

  • Within given, you first call mockSession.givenDispatchQueue(). This tells mockSession to create a MockURLSessionDataTask that dispatches its completionHandler on an internal queue. Then, you also create sut, passing .main for its responseQueue and mockSession for its session. Lastly, you create expectedImage, receivedThread and expectation.
  • Within when, you call sut.downloadImage. Inside its completion, you set receivedThread and fulfill the expectation. You then execute dataTask.completionHandler with image.pngData().
  • Within then, you wait until the expectation is fulfilled. Afterwards, you assert receivedThread.isMainThread.

This is similar to how you verified DogPatchClient dispatched to its responseQueue. While you can’t directly get the dispatch queue your code is executing, you can get the current thread and check if it’s the main thread. Until Apple provides a way to check the current dispatch queue, this is “good enough” for test purposes.

Build and run this test to verify it fails. To make it pass, replace this code within downloadImage on ImageClient:

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

…with this code instead, being careful not to change any code before or after this:

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

You made two changes here:

  1. You first declare [weak self] and then immediately call guard let self within the closure. This prevents a strong reference cycle due to capturing self.
  2. If you’re able to create an image, you check if responseQueue is set and dispatch completion to it.
  3. If responseQueue isn’t set, you call the completion directly.

Build and run the tests; the last one will now pass.

For the refactor step, you’ll move expectedImage into a property to get rid of the duplicated code. Add this line after the other properties:

var expectedImage: UIImage!

You also need to release this in tearDown(), so add this right before calling super.tearDown():

expectedImage = nil

Lastly, add this code right after tearDown():

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

Great! You can now use this helper method to get rid of the duplication in the tests. Replace the lines for let expectedImage = everywhere in ImageClientTests with the following:

givenExpectedImage()

Build and run the tests, and they should all continue to pass.

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

This test is very similar to the success case. The main difference is that you’re passing an error to the dataTask.completionHandler instead of an image.

Build and run the tests to verify this fails. Then, replace this code within downloadImage onImageClient:

completion(nil, error)

With this instead:

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

Build and run the tests again to verify they all pass.

You’ve duplicated logic in both the app and test code, so you need to refactor it!

You’ll first update the app code. Specifically, you need a new method to handle dispatching to the responseQueue. Add the following right after 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) }
}

This method accepts an image, error and completion. It then verifies if responseQueue is set. If it’s not, it calls completion directly. If it is, then it dispatches completion to the responseQueue.

You can now use this to remove the duplicate app logic. Replace these lines in downloadImage on ImageClient:

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

With this one line:

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

Then, replace these lines:

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

With this line:

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

Build and run the tests to verify they all still pass.

Next, you need to refactor the tests. Specifically, there’s a lot of duplicated code for verifying that you are dispatching to the responseQueue.

Add this right after whenDownloadImage towards the top of the file:

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

This code is very similar to how the last two unit tests validate receivedThread.isMainThread. However, it accepts an image, error and line as inputs. It uses these to call dataTask.completionHandler and then XCTAssert.

You’ve duplicated expectedError in a couple of places, so you’ll move this into a property. Add this line after the other properties:

var expectedError: NSError!

Just like the others, you also need to ensure expectedError is reset after each test run. Add this line to tearDown right before super.tearDown():

expectedError = nil

You also need a helper method to set expectedError. Add this right after givenExpectedImage:

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

You can now update the unit tests to make use of these methods. First, replace the line for let expectedError = within test_downloadImage_givenError_callsCompletionWithError with this:

givenExpectedError()

Then, replace the entire contents of test_downloadImage_givenImage_dispatchesToResponseQueue with this:

// given
givenExpectedImage()

// then
verifyDownloadImageDispatched(image: expectedImage)

Lastly, replace the contents of test_downloadImage_givenError_dispatchesToResponseQueue with this:

// given
givenExpectedError()

// then
verifyDownloadImageDispatched(error: expectedError)

Very nice! You’ve greatly simplified these tests by using your helper methods.

Build and run the unit tests to verify they all pass.

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.

Add the following test right after the last one:

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

This test asserts that the expected image is cached. Build and run the tests to validate this fails. To make it pass, add the following right after the if let data = line within downloadTask on ImageClient:

self.cachedImageForURL[url] = image

Build and run the test again to ensure it passes.

If there’s already a cached image, you don’t want to start another URLSessionDataTask. Instead, you should immediately call the completion with it and return nil from downloadTask.

Add the following test next:

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

You pass expectedImage into whenDownloadImage, which caches the image. You then call this method a second time and assert that receivedDataTask is nil.

Build and run this test to ensure it fails. To make it pass, change the return type for downloadImage on ImageClient from URLSessionDataTask to URLSessionDataTask?.

However, this causes a compiler error because ImageClient no longer conforms to ImageService. Change the return type for downloadImage within ImageService to URLSessionDataTask? as well.

Then, add these lines to downloadImage on ImageClient, right after the method’s opening curly brace:

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

You check if an image already exists in the cachedImageForURL; if so, you return nil for the data task.

Build and run the unit tests to verify they all now pass.

If there’s a cached image, you also need to immediately call completion with it. Add this test to verify this behavior happens:

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

You call whenDownloadImage with expectedImage and then immediately reset receivedImage to nil. This ensures receivedImage isn’t set per this first call. You call whenDownloadImage again with expectedImage and assert receivedImage is set.

Build and run to ensure this test fails. To make it pass, add the following right after if let image = cachedImageForURL[url] {:

completion(image, nil)

You immediately execute completion if the image is found in the cache.

Build and run the tests again, and they should all now pass.

Setting an image view from a URL

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

You’ll implement this as a convenience method for setting an image on an image view from a URL. But wait! Can’t you just call downloadImage(fromURL:, completion:) directly?

You could, but you’d need to handle caching logic: What happens if you’re already downloading an image for the image view? For example, what happens if you’re displaying the image view in a table view… which is exactly what ListingsViewController does?

In this case, you’d need to do the following:

  1. Cancel the cached data task for the image view, if one exists.
  2. Set a placeholder image on the image view.
  3. Call downloadImage and cache the data task for the image view.
  4. Remove the cached data task for the image view.
  5. Set the downloaded image on the image view.
  6. Handle what happens if an error is received.

You now have a plan for implementing setImage(on:,fromURL:,withPlaceholder:)!

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

You create a dataTask and imageView and insert these into sut.cachedTaskForImageView. You then call setImage and assert that dataTask.calledCancel is true.

Build and run the tests to verify this one fails. To get it to pass, add the following code within setImage on ImageClient:

cachedTaskForImageView[imageView]?.cancel()

You cancel the data task, if one exists for the imageView within cachedTaskForImageView.

Update MockURLSessionDataTask by adding the following code before the final closing class brace:

var calledCancel = false
  override func cancel() {
    calledCancel = true
  }

This allows the MockURLSessionDataTask to set a property whenever cancel() is called.

Build and run the tests again, and they’ll now pass.

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

You call givenExpectedImage() to set expectedImage and then create an imageView. You then call setImage with imageView and expectedImage and then assert the data for imageView.image equals the data for the expectedImage.

Build and run the tests, and you’ll see this last one fails. To make it pass, you want to set the image on the imageView to the placeholder. To do it, add this to setImage on ImageClient, right before the closing method brace:

imageView.image = placeholder

Build and run the tests again to ensure they all pass.

Is there anything to refactor? Yes, you’ve duplicated imageView in two tests. To eliminate the duplication, add this property after the others in ImageClientTests:

var imageView: UIImageView!

Then, add this line within tearDown to reset imageView after each run, right before calling super.tearDown():

imageView = nil

While you could create a helper method for givenImageView(), you’ll be using imageView in several tests. Hence, you’ll set it before each test run instead. Add this line to setUp(), right before setting sut:

imageView = UIImageView()

Finally, delete each let imageView line to eliminate the duplication.

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

You call sut.setImage, unwrap receivedDataTask using imageView in cachedTaskForImageView and then assert dataTask.url equals url.

Build and run the tests to verify this fails. To make it pass, add the following to setImage on ImageClient, immediately before the method’s closing brace:

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

}

Build and run the tests to verify the last one now passes.

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

You call setImage and unwrap receivedDataTask. You then call completionHandler on receivedDataTask and finally assert the data task is removed from the cache.

Build and run this test to verify it falls. To make it pass, add this line within setImage, immediately after the guard statement you added before:

self.cachedTaskForImageView[imageView] = nil

This removes the cached data task for imageView from cachedTaskForImageView.

Build and run the tests again to verify they all pass.

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

This test is very similar to the last one; the difference is you assert that the image data from the imageView equals the data from the expectedImage.

Build and run this test, and you’ll see it fails. To make it succeed, add this code within downloadImage on ImageClient after the previous line you added to set the image on the imageView:

imageView.image = image

Build and run to verify the test now passes. However, you now have duplicated code that needs refactoring.

Add the following code right after whenDownloadImage:

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

You’ve moved the common code into this method. Hence, you’ll need to replace the contents of test_setImageOnImageView_onCompletionRemovesCachedTask with the following:

// when
whenSetImage()

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

Then, replace the contents of test_setImageOnImageView_onCompletionSetsImage with this:

// when
whenSetImage()

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

Nice! This makes both of these tests much simpler.

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

Here’s how this works:

  • Within given, you call givenExpectedImage() to create expectedImage and givenExpectedError to create expectedError.
  • Within when, you call setImage, unwrap the data task and execute its completionHandler with the expectedError. As a consequence, this sets the expectedImage on the imageView because it’s passed as the placeholder image.
  • Within then, you assert that the image on the imageView is still set to the expectedImage.

Build and run this test to verify that it fails. To make it pass, replace this line within downloadImage on ImageClient:

imageView.image = image

With this instead:

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

You guard that image is actually set here. If it is not, you print the error to the console. If it is, you set it on the imageView.

Build and run the test, and they’ll all pass.

Using the image client

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

Before you do, you need to create a MockNetworkClient. Create a new Swift File in DogPatchTests/Test Types/Mocks named MockImageService.swift. Replace its contents with the following:

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

Here’s how this works:

  1. You import DogPatch and UIKit and create a new MockImageService that conforms to ImageService.
  2. You implement downloadImage because MockImageService requires it, but you won’t actually need it for now. Hence, you simply return nil from it.
  3. You declare properties for the setImageCallCount and received values.
  4. You implement setImage, per the other method required by MockImageService. Therein, you increment setImageCallCount and set each of the received properties.

You can now put this mock to good use! Open ListingsViewControllerTests.swift and add the following right before // MARK: - View Life Cycle - Tests:

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

You here cast sut.imageClient as AnyObject to silence a warning and then assert it is an ImageService. This test doesn’t compile, however, because you haven’t declared imageClient on ListingsViewController yet.

Add the following property after var networkClient within ListingsViewController:

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

Build and run the tests, and the last one should now succeed.

Next, add this test right after test_imageClient_isImageService to ensure that imageClient is actually set to ImageClient.shared:

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

Build and run this test to ensure it fails. To make it pass, update the var imageClient declaration in ListingsViewController with the following:

var imageClient: ImageService = ImageClient.shared

Build and run the tests again to ensure they pass.

You next need a MockImageClient to set as the imageClient on ImageService. To ensure you don’t accidentally make real network calls, you’ll create this within setUp().

Before you can do this, you first need a new property for mockImageClient. Add this right before var mockNetworkClient on ListingsViewControllerTests:

var mockImageClient: MockImageService!

Then add the following, right after setting sut within setUp():

mockImageClient = MockImageService()
sut.imageClient = mockImageClient

Add the following line of code to tearDown() to set mockImageClient to nil:

mockImageClient = nil

If you build and run the tests, however, you’ll see that test_imageClient_setToSharedImageClient now fails! This is because you set sut.imageClient within setUp to mockImageClient, and hence, it’s never going to be equal to ImageClient.shared.

Fortunately, this fix is easy. Add the following line to test_imageClient_setToSharedImageClient right after // given:

sut = ListingsViewController.instanceFromStoryboard()

Build and run the tests again, and they’ll all pass now.

You’re now ready to use mockImageClient in a unit test. Add this test after the very last one:

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)
}
  • Within given, you call givenMockViewModels() to create an array of view models and set this on sut.
  • Within when, you dequeue the cell for the first IndexPath and cast this to ListingTableViewCell.
  • Within then, you assert that receivedImageView on mockImageClient matches the dogImageView on the cell.

Build and run this test to verify it fails. To make it pass, you need to actually pass cell.dogImageView into imageClient.setImage. Add this code within listingCell(_:, _:) on ListingsViewController, right before the return line:

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

Build and run the tests again, and the last one will now pass.

You also need to ensure that you’re passing the correct URL into imageClient.setImage. Add this test after the last one:

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

Similar to the last test, you first call givenMockViewModels() to set sut.viewModels and then get the first one from it. You then call sut.tableView(_:, cellForRowAt:) to trigger configuring the first cell and then assert mockImageClient.receivedURL equals the viewModel.imageURL.

Build and run the tests, and you’ll see this one fails. To make it succeed, replace this argument within ListingsViewController:

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

With this code:

viewModel.imageURL

This passes the imageURL from viewModel into the imageClient.setImage call.

Build and run the tests to ensure they all pass.

For the refactor step, you now have similar code in the last two tests. To get rid of the duplication, add the following method after whenDequeueTableViewCells:

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

You here dequeue the first table view cell and then cast it as ListingTableViewCell.

Next, replace the when section in test_tableViewCellForRowAt_callsImageClientSetImageWithDogImageView with the following:

// when
let cell = whenDequeueFirstListingsCell()

Then, replace the when section in test_tableViewCellForRowAt_callsImageClientSetImageWithURL with this:

whenDequeueFirstListingsCell()

Great, that takes care of the duplicated code!

Lastly, you need a test to confirm that you’re passing the placeholder image into setImage. Add this test after the last one:

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

This test is similar to the previous ones. However, you here declare an expected placeholder and assert that the underlying data on mockImageClient.receivedPlaceholder is the same as it.

Build and run the tests to confirm this one fails. To make it pass, replace the following in ListingsViewController:

withPlaceholder: nil

With this instead:

withPlaceholder: 
  UIImage(named: "image_placeholder")

Build and run your tests again and they should all pass.

Now for the fun part – you’ve done TDD to create and even use the ImageClient, but you haven’t seen your hard work pay off yet. You’re finally ready to use it!

Build and run the app to check and see how ImageClient loads and displays images onscreen.

Key points

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

  • You created a service protocol to make mocking easy, just like with a network client.
  • You created downloadImage(...) to handle one-off image download requests and to cache downloaded images.
  • You created setImage(...) to make setting an image from a URL on an image view more convenient.
  • Remember to refactor as you go! For example, you can pull out helper methods and properties for turning asynchronous “download” calls into synchronous tests.

You’ve created the core functionality for DogPatch and learned a lot about networking along the way! There’s still more functionality you could add, including

  • Authentication
  • Messaging
  • Ratings
  • User preferences
  • And much more!

Some of these would require back-end support, but you can add many local features too. Of course, remember to do TDD for networking and local features alike! :]

Feel free to tinker with DogPatch as much as you’d like. When you’re ready, move onto the next section to learn about TDD on a legacy app.

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.
© 2026 Kodeco Inc.