-
Notifications
You must be signed in to change notification settings - Fork 7
Chapter 09: Albums List Row Model
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.