-
Notifications
You must be signed in to change notification settings - Fork 7
Chapter 08: Albums List Model
We’re almost ready to put our views on screen. Our last step will be to wrap our networking types with new types appropriate for managing the data we plan to display. These types will make use of the type methods from our previous chapters to save state to instance variables. These types will perform work asynchronously, but will manage their concurrency in a way that is safe for passing to views (on the main thread). Let’s start with the type we need to display a list of albums. This type will be responsible for downloading the list of Top Albums and saving those custom types to an instance variable.
Add a new Swift file named AlbumsListModel.swift Add this file to your Albums target and your AlbumsTests target. We will need one test-double type. Let’s define the requirements of this test-double with a protocol.
// AlbumsListModel.swift
import Foundation
protocol AlbumsListModelJSONOperation {
associatedtype JSON
static func json(for: URLRequest) async throws -> JSON
}
extension NetworkJSONOperation : AlbumsListModelJSONOperation where Session == NetworkSession<Foundation.URLSession>, JSONHandler == NetworkJSONHandler<NetworkDataHandler, Foundation.JSONSerialization> {
}Let’s go ahead and define the new custom type we will use to hold the data for each album.
// AlbumsListModel.swift
struct Album {
let id: String
let artist: String
let name: String
let image: String
}
extension Album : Hashable {
}
extension Album : Identifiable {
}There are four properties we will need to define each Album instance.
-
id: We want to use theseAlbuminstances to power a SwiftUIList. Making theAlbumtypeIdentifiablewill make things easier for us when we build ourList. -
artist: We want to display the name of each artist in ourList. -
name: We want to display the name of each album in ourList. -
image: We want to display the album artwork. We will need to save the URL address where we can request that artwork.
Let’s define the type we plan to test. Let’s also define one instance method (for requesting the albums) and one instance variable (for saving the albums). We’ll be making use of Combine to publish changes to our SwiftUI views. Our instance variable will be Published and our type will be MainActor.
// AlbumsListModel.swift
@MainActor final class AlbumsListModel<JSONOperation : AlbumsListModelJSONOperation> : ObservableObject {
@Published private(set) var albums = Array<Album>()
}
extension AlbumsListModel {
func requestAlbums() async throws {
}
}Add a new Swift file named AlbumsListModelTests.swift. Add this file to your AlbumsTests target. We only need one test-double type. Let’s define that.
// AlbumsListModelTests.swift
import XCTest
final class AlbumsListModelTestCase : XCTestCase {
private typealias AlbumsListModelTestDouble = AlbumsListModel<JSONOperationTestDouble>
}
extension AlbumsListModelTestCase {
private struct JSONOperationTestDouble : AlbumsListModelJSONOperation {
static var parameterRequest: URLRequest?
static var returnJSON: Any?
static let returnError = NSErrorTestDouble()
static func json(for request: URLRequest) async throws -> Any {
self.parameterRequest = request
guard
let returnJSON = self.returnJSON
else {
throw self.returnError
}
return returnJSON
}
}
}Let’s implement a tearDown method to give every test a consistent state.
// AlbumsListModelTests.swift
extension AlbumsListModelTestCase {
override func tearDown() {
JSONOperationTestDouble.parameterRequest = nil
JSONOperationTestDouble.returnJSON = nil
}
}Let’s write our first test. We will assume our JSONOperation threw an error; no JSON object was returned. Our instance method should throw the same error, and our instance variable should be an empty array. Our test method will be MainActor (since the type we are testing is MainActor).
// AlbumsListModelTests.swift
extension AlbumsListModelTestCase {
private static var request: URLRequest {
return URLRequest(url: URL(string: "https://itunes.apple.com/us/rss/topalbums/limit=100/json")!)
}
}
extension AlbumsListModelTestCase {
@MainActor func testError() async {
JSONOperationTestDouble.returnJSON = nil
let model = AlbumsListModelTestDouble()
do {
try await model.requestAlbums()
XCTFail()
} catch {
XCTAssertEqual(
JSONOperationTestDouble.parameterRequest,
Self.request
)
XCTAssertEqual(
model.albums,
[]
)
if let error = try? XCTUnwrap(error as NSError?) {
XCTAssertIdentical(
error,
JSONOperationTestDouble.returnError
)
}
}
}
}Command-U. Tests fail. Let’s make use of the JSONOperation type and throw the correct error.
// AlbumsListModel.swift
extension AlbumsListModel {
func requestAlbums() async throws {
if let url = URL(string: "https://itunes.apple.com/us/rss/topalbums/limit=100/json") {
let request = URLRequest(url: url)
try await JSONOperation.json(for: request)
}
}
}Command-U. Tests pass. Let’s put a little thought into our next test. We would like to assume the JSONOperation returned a valid JSON object. We would also like to assume the instance variable would save the correct list of albums. How would we test this? We have the ability to stub a JSON object to be returned by the JSONOperation test-double. We need to test that our production type is correctly transforming that JSON object to an array of Album values.
We will save a JSON response to our local test bundle. We will transform that JSON response to an array of Album values in our test method. We will stub that same JSON response on our JSONOperation test-double. We will then compare the array from our test method and our instance variable for equality. If the two arrays are equal, our test passes.
Add a new empty file named Albums.json. Add this file to your AlbumsTests target. Download the response from Apple at the address we defined in AlbumsListModelTestCase.request and save this in Albums.json. Go ahead and spend a little time inspecting the JSON to familiarize yourself with the structure. We need four different properties to construct each Album value. Let’s read the JSON file from our test bundle (make sure you set the identifier to the same identifier as your test target) and construct the array we are looking for in a test method.
Our type will use the Combine framework to make its state observable to SwiftUI. Our type inherits from ObservableObject. Our instance variable is Published. We should incorporate that behavior in our test. Let’s make use of the two Publisher properties on this type to verify that our type is behaving correctly. We will pass one closure to the Publisher of our instance. That closure will set a flag to indicate we have made a change to our instance. We will pass one more closure to the Publisher of our instance variable. That closure will set one more flag to indicate we have made a change to our instance variable. We can then test that our second closure executed after our first closure.
// AlbumsListModelTests.swift
extension AlbumsListModelTestCase {
private static var json: Any {
let bundle = Bundle(identifier: "com.northbronson.AlbumsTests")!
let url = bundle.url(
forResource: "Albums",
withExtension: "json"
)!
let data = try! Data(contentsOf: url)
let json = try! JSONSerialization.jsonObject(
with: data,
options: []
)
return json
}
}
private func Albums(_ json: Any) -> Array<Album> {
var albums = Array<Album>()
if let array = ((json as? Dictionary<String, Any>)?["feed"] as? Dictionary<String, Any>)?["entry"] as? Array<Dictionary<String, Any>> {
for dictionary in array {
if let artist = ((dictionary["im:artist"] as? Dictionary<String, Any>)?["label"] as? String),
let name = ((dictionary["im:name"] as? Dictionary<String, Any>)?["label"] as? String),
let image = ((dictionary["im:image"] as? Array<Dictionary<String, Any>>)?[2]["label"] as? String),
let id = (((dictionary["id"] as? Dictionary<String, Any>)?["attributes"] as? Dictionary<String, Any>)?["im:id"] as? String) {
let album = Album(
id: id,
artist: artist,
name: name,
image: image
)
albums.append(album)
}
}
}
return albums
}
extension AlbumsListModelTestCase {
private static var albums: Array<Album> {
return Albums(self.json)
}
}
extension AlbumsListModelTestCase {
@MainActor func testSuccess() async {
JSONOperationTestDouble.returnJSON = Self.json
let model = AlbumsListModelTestDouble()
var modelDidChange = false
let modelWillChange = model.objectWillChange.sink() { _ in
modelDidChange = true
}
var albumsDidChange = false
let albumsWillChange = model.$albums.sink() { _ in
if modelDidChange {
albumsDidChange = true
}
}
do {
try await model.requestAlbums()
XCTAssertTrue(albumsDidChange)
XCTAssertEqual(
JSONOperationTestDouble.parameterRequest,
Self.request
)
XCTAssertEqual(
model.albums,
Self.albums
)
} catch {
XCTFail()
}
modelWillChange.cancel()
albumsWillChange.cancel()
}
}Command-U. Tests fail. If this test crashed, check to verify you are trying to read the JSON file from the correct bundle; your bundle identifier should be the same identifier as your test target. See “Managing Your App’s Information Property List” from Apple for directions to find the bundle identifier.[^1]
Let’s update our implementation to create the same array we created in our test method. What would be a simple implementation to make this test pass? We can copy our helper function from our tests. We already wrote all the code we need to parse that JSON. What happens if we just copy-and-paste that implementation in our production type?
// AlbumsListModel.swift
private func Albums(_ json: Any) -> Array<Album> {
var albums = Array<Album>()
if let array = ((json as? Dictionary<String, Any>)?["feed"] as? Dictionary<String, Any>)?["entry"] as? Array<Dictionary<String, Any>> {
for dictionary in array {
if let artist = ((dictionary["im:artist"] as? Dictionary<String, Any>)?["label"] as? String),
let name = ((dictionary["im:name"] as? Dictionary<String, Any>)?["label"] as? String),
let image = ((dictionary["im:image"] as? Array<Dictionary<String, Any>>)?[2]["label"] as? String),
let id = (((dictionary["id"] as? Dictionary<String, Any>)?["attributes"] as? Dictionary<String, Any>)?["im:id"] as? String) {
let album = Album(
id: id,
artist: artist,
name: name,
image: image
)
albums.append(album)
}
}
}
return albums
}
extension AlbumsListModel {
func requestAlbums() async throws {
if let url = URL(string: "https://itunes.apple.com/us/rss/topalbums/limit=100/json") {
let request = URLRequest(url: url)
let json = try await JSONOperation.json(for: request)
self.albums = Albums(json)
}
}
}Command-U. Tests pass. Is this “cheating”? Not necessarily. We still started with test code to define the functionality we expected from our production type. We started with a failing test. We then wrote app code to make that test code pass. This is consistent with TDD.
Does writing the same code in two different places imply bad design? Not necessarily. As Jon Reid used to say: You can think of TDD like double-entry bookkeeping. We wrote this code in two places; if that code should change in one place (like our production type), then our test method would fail. While writing the same code two different places in your production target might be a sign you can refactor, writing the same code in your app target and your test target might not.
You app code should not depend on your test code (in the sense that your app code makes use of types or methods defined in tests). Your test code should be treated as “scaffolding” that you strip away when you give your app to your customers (which is why we don’t compile our test types in our app target).
While our test code should depend on our app code, that does not mean that we should factor this logic out of our test code. If we defined this logic only in our app code, and we depended on that logic in our test code, we would no longer have very reliable tests. An engineer could break this logic in our app code, our test code would import that same broken logic, our tests would continue to pass, and we could sneak a bug through to our customers.
We’ve taken a (slightly) different approach for testing this type. While we still injected a test-double for spying and stubbing our JSON request, we performed a transformation on that JSON object by working directly with regular Apple collection types (arrays and dictionaries). There’s a subtle philosophical distinction between these two styles of testing. For most of this tutorial, we’ve focused on testing relationships between types. We spy and stub to test that the types we compose make correct use of each other. For this type, we tested the algorithm. We passed in a real JSON object and tested by performing a live transformation on that JSON object.
Engineers that practice TDD often speak of “Classic” TDD and “London” TDD. Classic TDD tends to favor testing algorithms (with less use of test-double types). London TDD tends to favor testing relationships (with more use of test-double types). A thorough discussion of these two styles is outside the scope of this tutorial; engineers like Martin Fowler have written about this topic in more detail.[^2]
This type might not look exactly like what we’ve done before, but that’s not a bad thing. Different engineering tasks might need different approaches to testing. We still wrote our test code first, we still wrote app code to make our test code pass, and we still feel confident that this type performs correctly.
[^1]: Apple Inc. Managing Your App’s Information Property List.
[^2] Fowler, Martin (2007-01-02). Mocks Aren’t Stubs.