Skip to content

Chapter 09: Albums List Row Model

Rick van Voorden edited this page Nov 8, 2021 · 1 revision

We’re almost ready to start building some views. There’s just one type left we need to build. We have our type to request the list of albums and save them to an instance variable. We need one more type to request the artwork of one album (and save it to an instance variable).

Add a new Swift file named AlbumsListRowModel.swift Add this file to your Albums target and your AlbumsTests target. Let’s define the interface our test-double type will need to request an image.

//  AlbumsListRowModel.swift

import Foundation

protocol AlbumsListRowModelImageOperation {
  associatedtype Image
  
  static func image(for: URLRequest) async throws -> Image
}

extension NetworkImageOperation : AlbumsListRowModelImageOperation where Session == NetworkSession<Foundation.URLSession>, ImageHandler == NetworkImageHandler<NetworkDataHandler, NetworkImageSerialization<NetworkImageSource>> {
  
}

Let’s define the type we will be testing. Let’s also define the methods we need (along with some empty implementations).

//  AlbumsListRowModel.swift

@MainActor final class AlbumsListRowModel<ImageOperation : AlbumsListRowModelImageOperation> : ObservableObject {
  @Published private(set) var image: ImageOperation.Image?
  
  init(album: Album) {
    
  }
}

extension AlbumsListRowModel {
  var artist: String {
    return String()
  }
}

extension AlbumsListRowModel {
  var name: String {
    return String()
  }
}

extension AlbumsListRowModel {
  func requestImage() async throws {
    
  }
}

Add a new Swift file named AlbumsListRowModelTests.swift. Add this file to your AlbumsTests target. Let’s start with defining our test-double type.

//  AlbumsListRowModelTests.swift

import XCTest

final class AlbumsListRowModelTestCase : XCTestCase {
  private typealias AlbumsListRowModelTestDouble = AlbumsListRowModel<ImageOperationTestDouble>
}

extension AlbumsListRowModelTestCase {
  private struct ImageOperationTestDouble : AlbumsListRowModelImageOperation {
    static var parameterRequest: URLRequest?
    static var returnImage: NSObject?
    static let returnError = NSErrorTestDouble()
    
    static func image(for request: URLRequest) async throws -> NSObject {
      self.parameterRequest = request
      guard
        let returnImage = self.returnImage
      else {
        throw self.returnError
      }
      return returnImage
    }
  }
}

Don’t forget to implement tearDown and give our tests a consistent state.

//  AlbumsListRowModelTests.swift

extension AlbumsListRowModelTestCase {
  override func tearDown() {
    ImageOperationTestDouble.parameterRequest = nil
    ImageOperationTestDouble.returnImage = nil
  }
}

Let’s write our first test. We will create an AlbumsListRowModel instance with an Album value. We will check that the properties of that Album are being persisted. We will try to request an image. We will fail and throw the error from our ImageOperation.

//  AlbumsListRowModelTests.swift

extension AlbumsListRowModelTestCase {
  private static var album: Album {
    return Album(
      id: "id",
      artist: "artist",
      name: "name",
      image: "image"
    )
  }
}

extension AlbumsListRowModelTestCase {
  @MainActor func testError() async {
    ImageOperationTestDouble.returnImage = nil
    
    let model = AlbumsListRowModelTestDouble(album: Self.album)
    XCTAssertEqual(
      model.artist,
      Self.album.artist
    )
    XCTAssertEqual(
      model.name,
      Self.album.name
    )
    
    do {
      try await model.requestImage()
      XCTFail()
    } catch {
      XCTAssertEqual(
        ImageOperationTestDouble.parameterRequest,
        URLRequest(url: URL(string: Self.album.image)!)
      )

      XCTAssertNil(model.image)
      
      if let error = try? XCTUnwrap(error as NSError?) {
        XCTAssertIdentical(
          error,
          ImageOperationTestDouble.returnError
        )
      }
    }
  }
}

Command-U. Tests fail. Let’s start with the properties that should be persisted.

//  AlbumsListRowModel.swift

@MainActor final class AlbumsListRowModel<ImageOperation : AlbumsListRowModelImageOperation> : ObservableObject {
  @Published private(set) var image: ImageOperation.Image?
  
  private let album: Album
  
  init(album: Album) {
    self.album = album
  }
}

extension AlbumsListRowModel {
  var artist: String {
    return self.album.artist
  }
}

extension AlbumsListRowModel {
  var name: String {
    return self.album.name
  }
}

Command-U. Tests fail. Let’s try and make correct use of the ImageOperation type.

//  AlbumsListRowModel.swift

extension AlbumsListRowModel {
  func requestImage() async throws {
    if let url = URL(string: self.album.image) {
      let request = URLRequest(url: url)
      try await ImageOperation.image(for: request)
    }
  }
}

Command-U. Tests pass. Let’s add our final test. We will assume the ImageOperation returned a valid image. Similar to what we did in the last chapter, we will also verify our type is making correct use of the Combine framework to publish its state.

//  AlbumsListRowModelTests.swift

extension AlbumsListRowModelTestCase {
  @MainActor func testSuccess() async {
    ImageOperationTestDouble.returnImage = NSObject()
    
    let model = AlbumsListRowModelTestDouble(album: Self.album)
    
    var modelDidChange = false
    let modelWillChange = model.objectWillChange.sink() { _ in
      modelDidChange = true
    }
    
    var imageDidChange = false
    let imageWillChange = model.$image.sink() { _ in
      if modelDidChange {
        imageDidChange = true
      }
    }
    
    XCTAssertEqual(
      model.artist,
      Self.album.artist
    )
    XCTAssertEqual(
      model.name,
      Self.album.name
    )
    
    do {
      try await model.requestImage()
      
      XCTAssertTrue(imageDidChange)
      
      XCTAssertEqual(
        ImageOperationTestDouble.parameterRequest,
        URLRequest(url: URL(string: Self.album.image)!)
      )
      
      XCTAssertIdentical(
        model.image,
        ImageOperationTestDouble.returnImage
      )
    } catch {
      XCTFail()
    }
    
    modelWillChange.cancel()
    imageWillChange.cancel()
  }
}

Command-U. Tests fail. This should be an easy fix. Let’s update our implementation and save the image to our instance variable.

//  AlbumsListRowModel.swift

extension AlbumsListRowModel {
  func requestImage() async throws {
    if let url = URL(string: self.album.image) {
      let request = URLRequest(url: url)
      let image = try await ImageOperation.image(for: request)
      self.image = image
    }
  }
}

Command-U. Tests pass. We have all the types we need to start building views. While this is the tenth type we’ve built for this tutorial, we only “need” two types to be fully exposed to our views (AlbumsListModel and AlbumsListRowModel). While we could have started a conventional tutorial by building these two types first (and added networking logic and data serialization as necessary), we might have introduced hidden dependencies. We might have built an AlbumsListModel with a “locked-down” dependency on the Apple JSONSerialization class. We might have built an AlbumsListRowModel with a locked-down dependency on the Apple Image I/O functions. Both of those types might been built with locked-down dependencies on the Apple URLSession class. When it came time to confirm our application was behaving correctly, we might have to build and deploy our application to a simulator and wait for real network requests to complete.

In all the time we’ve spent building our tutorial, we’ve never once needed to build and run our application live in a simulator (or on a device). We build and run tests. Our simple tests verify simple behavior. We compose simple types to build more complex types. While practicing composition and dependency-injection does not mean you must practice TDD, TDD does help to encourage to composition and dependency-injection.

Clone this wiki locally