Top 100 Albums app – my approach to a code exercise

The completed project

I recently completed the development of an iOS app as a code exercise.   The goal was to display the top 100 albums across all genres using the Apple RSS generator.    You can find the TopAlbums project on GitHub.  The project description contains an animated GIF of the finished product, along with the detailed set of requirements.

What I wanted to do in this post is review my thought process in developing the app, along with any intricacies I came across.

Table of Contents

Initial Setup
Model Creation
Album Networking Client
Album Unit Tests
Image Networking Client
Image Unit Tests
Albums Table
Album View
iTunes Store button
Code Coverage
View model unit tests
UI Tests
Wrapping it up
 

Initial Setup

I started with some basic setup after creating the initial project.

Remove Storyboard

There are are a couple of edits to Info.plist that you need to make in order to remove references to the storyboard.

  • Remove the key “Main storyboard file base name” in Info.plist
  • Remove the key Application Screen Manifest -> Scene Configuration -> Application Session Role -> Item 0 -> “Storyboard Name”

Create root view controller

Without a storyboard, you need to manually set the root view controller.   I created a placeholder view controller where I would display albums in a table view, and embedded that view controller in a navigation controller.   The navigation controller would serve as the root view controller.

The relevant code in SceneDelegate is:

let albumsViewController = AlbumsViewController()
let navigationController = UINavigationController(rootViewController: albumsViewController)

window.rootViewController = navigationController

Folders

I decided to use the MVVM design pattern for this project and thus setup the following  folder structure.

Table of Contents 

Model Creation

In order for my app to begin to have any functionality, I needed to first setup my models.    The requirements indicated that the Apple RSS generator could be found at https://rss.itunes.apple.com/en-us.   A review of that page showed that the endpoint I needed to download the JSON was at https://rss.itunes.apple.com/api/v1/us/apple-music/coming-soon/all/10/explicit.json.

quicktype

The endpoint for JSON had an option to download Raw Data.    I was able to copy that data and quickly generate some models using the online quicktype tool.   This tool is a great way to quickly parse some sample data and create models based on some user selections.

Trimming the models

A careful analysis of the requirements showed that I only needed a subset of the models that quicktype generated, and of those models I only needed a subset of properties.

I was able to trim the number of models down to four:

The models had this hierarchy:
AlbumFeed -> Feed -> Album -> Genre

CodingKeys

For a couple of models, I changed the property names to be more meaningful.   In order to be able to parse successfully after the changes, I added a CodingKeys enum.    One example is the Feed model:

struct Feed: Codable {
    let albums: [Album]

    enum CodingKeys: String, CodingKey {
      case albums = "results"
    }
}

Table of Contents 

Album Networking Client

Next I wanted to add the ability to download the JSON and decode into my models.    I created a networking client called AlbumClient.

There are three important properties that this client needed:

  • albumFeedURL – The URL of where to get the JSON.
  • session – A URLSession used with the dataTask method (actually this is really of type DataTaskMaker which I describe below).
  • responseQueue – The DispatchQueue to be used for returning results.

Shared client

For normal app operation, it was only necessary to create one instance of AlbumClient which was referenced as AlbumClient.shared.

  • This shared instance used a feed URL that was referenced from an enum that I created in Constants.swift.
  • For the session I used URLSession.shared since no special configuration was needed.
  • The response queue was set to be DispatchQueue.main to ensure that results could be used in the main thread for UI.

Designated Initializer

Thinking ahead to the need to test the app, I realized that I would need a custom instance of AlbumClient.  For testing, the album URL and the response queue would be irrelevant, but controlling the session would be key.

What I needed was to be able to override the implementation of the dataTask method of URLSession in order to be able to use some canned data.

Data Task Maker

I learned a technique using protocols that was perfect for the dataTask implementation.   For reference see the video tutorial Testing in iOS (paid subscription needed).

What I do is to create a protocol called DataTaskMaker with a method for dataTask that has the same signature as that method in URLSession.  Using an extension on URLSession, I declare its conformance to DataTaskMaker, which it already does since it has an implementation of dataTask.

In AlbumClient, I declare session to be of type DataTaskMaker instead of URLSession.

AlbumClient.shared
For our shared AlbumClient, I set session (of type DataTaskMaker) to URLSession.shared as mentioned above.

Testing
Skipping ahead, this is how the test environment will set the session property of AlbumClient:

  • I create a class StubDataTaskMaker which will conform to DataTaskMaker, and implement a dataTask method.
  • An instance of StubDataTaskMaker is used as the session.
  • The dataTask method of our class will pull canned data from our app bundle, returning it in the completion handler.

get Album Feed

AlbumClient has one public method called getAlbumFeed.   The signature is:

func getAlbumFeed(
        completion: @escaping (AlbumFeed?, Error?) -> Void
    ) -> URLSessionDataTask?

This method returns an instance of our model AlbumFeed in a completion handler.

Table of Contents 

Album Unit Tests

With our models and album client in place, I proceeded directly to unit testing of these pieces.  This allowed me to flush out any issues before attempting to use the model data in the app implementation.

Model test

With the following I was able to verify that I was using the correct feed URL and could successfully decode into my models in a live test over the network.

    // Fetch JSON over the network and decode successfully
    func test_serverDecodeAlbumFeed() throws {

        let url = try XCTUnwrap(Constants.AlbumFeed.feedURL)

        URLSession.shared.dataTask(with: url) { data, response, error in
            defer { self.expectation.fulfill() }

            XCTAssertNil(error)

            // dataTask method is not throwing, so catch throws
            do {
                let response = try XCTUnwrap(response as? HTTPURLResponse)
                XCTAssertEqual(response.statusCode, 200)

                let data = try XCTUnwrap(data)
                XCTAssertNoThrow(
                    try JSONDecoder().decode(AlbumFeed.self, from: data)
                )
            } catch { }
        }
        .resume()

        waitForExpectations(timeout: timeout)
    }

Checking invalid decode

As a further check on my test code, I added a similar test to the above, but attempted to decode to a model that contained an extra property.   I made sure that an error was detected and the type was DecodingError.keyNotFound

Album Client test

Using the StubDataTaskMaker class conforming to DataTaskMaker, I wrote a test to download canned data from the bundle using the AlbumClient getAlbumFeed method.

Table of Contents 

Image Networking Client

The ImageClient had one public method called setImage with the signature:

    func setImage(on imageView: UIImageView,
                  fromURL url: URL,
                  withPlaceholder placeholder: UIImage?,
                  completion: @escaping (UIImage?, Error?) -> Void
    ) 

The completion block wasn’t part of normal operation, but was useful in testing.

The initializer for ImageClient was similar to AlbumClient, but did not have a URL (since this was part of setImage).

Internally ImageClient implemented a downloadImage method that was called by setImage. It called dataTask on the session (of type DataTaskMaker) and returned results on the chosen responseQueue.

Table of Contents 

Image Unit Tests

A couple image unit tests were implemented.

Album Artwork

One test downloaded image data over the network using a representative URL for album artwork. The data was used to initialize a UIImage. The URL was a constant, obtained from a previous downloaded copy of JSON from the API.

Image Client Test

Using the StubDataTaskMaker class conforming to DataTaskMaker, I wrote a test to download canned data from the bundle and use it to set an image on a UIImageView using the client’s setImage method. The test checked the following:

  • The image on the UIImageView was identical to that returned in the setImage completion block.
  • A PNG version of the image from the completion block was identical to that formed from a PNG from data taken directly from the bundle.

Table of Contents 

Albums Table

There were three pieces to the implementation of the Albums Table: AlbumsViewController, AlbumsTableViewCell and AlbumsViewModel.

Albums View Controller

The AlbumsViewController was responsible for presenting the table view of albums. It held a property for an instance of AlbumsViewModel which was injected on app startup from SceneDelegate.

Table View

On load of this VC the following would occur:

  • Register a UITableView and set itself as the delegate and datasource.
  • Set an estimated height
  • Add the table view as a subview.
  • Set constraints to position within the safe area (with the exception of the top which could scroll behind navigation bar).

Data Load

After the table view setup was handled a call to the view model was made to fetch the album JSON. The completion block would trigger a reload of the table data.

Cell configuration and number of rows was also obtained through the view model.

Row selection

Tapping on a row would cause an instance of AlbumViewController to be pushed onto the navigation controller, after setting its album property to the selected album (obtained from the view model).

Albums Table View Cell

The AlbumsTableViewCell initializer called a addSubviews method which would add elements to the cell, embedded in stackviews and constrained appropriately.

It was necessary to lower the content hugging priority of the vertical stack view used for the labels (albumName, artistName) in order to let it grow horizontally inside the row stack view (which included the thumbnail image) since the row stack view was using a fill distribution.

Albums View Model

This view model was responsible for various view state and configuration.

Load Albums

The loadAlbums method was responsible for using AlbumClient to download JSON data and populate our models. An albums property held an array of Album objects.

Cell configuration

The configure method was passed a AlbumsTableViewCell, index (into albums) and ImageClient. It was responsible for configuring the cell’s labels (text, font) as well as using the ImageClient to set an image on the cell’s UIImageView.

For testing purposes, I also set the accessibilityIdentifier for the albumName, artistName and cell itself. This was needed during UI testing.

Table of Contents 

Album View

There were three pieces to the implementation of the Album View: AlbumViewController, AlbumView and AlbumViewModel

Album View Controller

The AlbumViewController had very little responsibility. Besides holding the selected album in a property, it did the following:

loadView

This method was overriden to initialize an AlbumViewModel with album, an AlbumView with the view model, and set the view controller’s view to the AlbumView instance.

If we had instead chosen to add an AlbumView as a subview, we would have needed to add the appropriate constraints.

By replacing the view controller’s view directly with an AlbumView, we avoid needing to add constraints. The AlbumView instance will automatically constrain itself to the edges of our view controller.

viewDidLoad

All we did here was to disable the large title display mode for the navigation controller.

Album View

When an AlbumView is used as the view for AlbumViewController, it triggers a call to func willMove(toSuperview:)

I override this method to get a hook to add the elements of the album view screen. All the constraints are also added here (stack views, iTunes store button).

After all subviews are added, a call is made to the view model for configuration.

Album View Model

The AlbumViewModel is responsible for configuration of all the elements including formatting the release date, adding accessibility identifiers for UI testing, loading the image and providing the action for the iTunes store button.

A future improvement here would be to pass in the ImageClient in the configure call from AlbumView, rather than to use ImageClient.shared directly in AlbumViewModel. Doing so would allow testing of the ImageClient during view model testing of AlbumViewModel.

Table of Contents 

iTunes Store button

The iTunes Store button was constrained in AlbumView and configured in AlbumViewModel. I purposely set a border around the button for a couple reasons:

  • To visually show that constraints were setup per the code exercise requirements.
  • To display a border color that could be tested in light and dark mode.

Supporting dark mode turned out to be a little bit of a challenge. It wasn’t sufficient to set the color based on the trait collection, since the border color is set on the button’s layer and it is a CGColor, not a UIColor.

Setting the border color

In AlbumViewModel I created the following method to set the button’s border color upon initial configuration:

    func updateStoreButtonBorder(_ view: AlbumView) {
        view.storeButton.layer.borderColor = Constants.Album.buttonBorderColor.cgColor
    }

Trait collection based color

The color used for the button border came from Constants.swift and was based on a trait collection:

        static let buttonBorderColor = UIColor {(traitCollection: UITraitCollection) -> UIColor in
            if traitCollection.userInterfaceStyle == .dark {
                return UIColor.white
            } else {
                return UIColor.black
            }
        }

Trait collection changes

It was necessary to monitor for any changes to the trait collection, so that the button color could be updated. In AlbumView I created the following override:

    // Respond to trait collection changes
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)

        if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
            albumViewModel?.updateStoreButtonBorder(self)
        }
    }

Table of Contents 

Code Coverage

As I was heading into the home stretch, I decided to turn on code coverage to get a feel for where I stood with testing.

Running the tests showed that I still had work to do!

Table of Contents 

View model unit tests

Albums View Model

For the AlbumsViewModel we perform the following steps in a test:

  1. Get an AlbumClient (referencing canned data) and use the client to retrieve the albums.
  2. Using the first album, attempt to configure an instance of AlbumsTableViewCell using the view model. Check a couple fields (artistName, albumName). Expect a fail since we did not call loadAlbums on view model.
  3. Load the albums into view model and check the count.
  4. Retrieve the first album from view model and compare against what we originally loaded from step 1.
  5. Try to configure an instance of AlbumsTableViewCell again and expect a pass.

In order to perform step 4 of the test, I needed to modify the Album model to add conformance to Equatable.

    static func == (lhs: Album, rhs: Album) -> Bool {
        return
            lhs.artistName == rhs.artistName &&
            lhs.artworkURL == rhs.artworkURL &&
            lhs.copyright == rhs.copyright &&
            lhs.genres == rhs.genres &&
            lhs.name == rhs.name &&
            lhs.releaseDate == rhs.releaseDate &&
            lhs.albumURL == rhs.albumURL
    }

Album View Model

For the AlbumViewModel test we perform the following steps:

  1. Get an AlbumClient (referencing canned data) and use the client to retrieve the albums.
  2. Instance an AlbumView and an AlbumViewModel (using the first album we fetched).
  3. Check the album view before configuring and expect a fail.
  4. Configure the album view and recheck, expecting a pass.

Table of Contents 

UI Tests

The UI test was a simple navigation check:

  1. Check that the app starts with the table view.
  2. Record the album and artist name from the first cell.
  3. Tap the first row to navigate to the album page.
  4. Check album and artist name against what was recorded for table cell.
  5. Tap button to go back to table view and confirm that we landed there.

The challenging pieces

The UI Tests provided a couple of challenges:

  • I discovered that the accessibility identifiers must be unique in the app for simple queries from XCUIApplication to work properly (so that elements from table view row and album view are distinguishable).
  • The identifiers needed to be constant strings, not UUID().uuidString since Constants.swift was compiled separately for the app and any UI test that uses it.
  • A UI test cannot directly import TopAlbums, unlike a unit test, and thus I needed to include Constants.swift in the target membership of the TopAlbumsUITests target.

Table of Contents 

Wrapping it up

At the end I took the opportunity to perform some final refactoring:

  • I consolidated some code between my networking clients, creating the base class NetworkingClient.
  • I created AlbumUtilities to gather some re-usable code used in testing.
  • StubDataTaskMaker used in testing became a base class for StubAlbumDataTaskMaker and StubImageDataTaskMaker.

Final coverage results

The final code coverage results showed that I did well with the Unit and UI testing:

Table of Contents

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s