Steve On Stuff

The Humble TableView

05 Dec 2018

I’ve been thinking about tableviews recently. Table views and collection views have become the default way to build UI for many types of applications, especially content driven ones. As our table views become increasingly complex though, testing that they’re showing the correct content becomes difficult to do.

I think that much of the difficulty in creating testable tables stems from a pattern where we have model objects that describe our application’s domain, which we then try to show in a table. Whilst these models describe a particular concern of the application’s domain perfectly, they might not fully describe the content that we want to show in a particular table cell.

Let’s have a look at an example, and then we’ll see how we can improve things. I’m going to use a table view because it’s a little simpler, but you can apply these techniques to a collection view too.

Tables showing domain models

You probably have some model objects in your application that describe the domain that your app is concerned about. For example, if you make a social networking app you might have User, Friend and Message objects. You probably obtain these objects by creating them from Json representations that you get from your api, and use them throughout your application to model the application’s domain.

For our example we can imagine a music streaming application, with a screen for a particular artist that displays all of the tracks that can be played for that artist. We probably already have a domain object Track, so we can just get all of the Tracks for the artist, and show them in a table:

struct Track {
  let id: String
  let title: String
  let duration: Double
  let streamURL: URL
}

class TrackCell: UITableViewCell {
  func configure(track: Track) {
    // configure the cell with the track
  }
}

Ok, not bad! When the user opens the artist page, we’ll fetch the Tracks from our MusicAPI, and show them in the table. The cell can then show the track title and duration, and when the user presses play we can start streaming.

With this setup we go straight from Domain Models (Track) to UITableViewCells.

Domain models data flow

This strategy breaks down though when we have to display information in our cell that isn’t described by the Track object. Let’s imagine that we have a new requirement - Our application can now download tracks and we need to show the downloaded state of each track in the list.

Our UI will look something like this. In this example the third track has been downloaded but the others haven’t.

The artist page

Unfortunately our Track model now isn’t going to fully describe everything that a TrackTableViewCell needs to know. We get the Tracks from our MusicAPI, but we need to consult our DownloadsManager to find out if a track is downloaded locally or not.

Perhaps We could call out to the DownloadsManager when we create the cell and pass it the download state separately, or even call it from within the cell itself, like this:

class TrackCell: UITableViewCell {
  func configure(track: Track) {
    // configure the cell with the track
    downloadIcon.showDownloaded = DownloadsManager.hasDownloaded(track)
  }
}

It’s pretty difficult to test that our cell is showing the correct information though:

This is because the code that coordinates between the MusicAPI and the DownloadsManager is now in the UI portion of our app, which is really difficult to test. Our unit test would have to create an instance of the view controller that owns the table, scroll the table to ensure the cell that we want to inspect has been created, and reach in a check the state of its views (which should really be private anyway).

There must be a better way! Let’s look at how we can make this table more testable.

Making Cell Models

Rather than using domain models to configure our cells, we’ll make a TrackCellModel that will fully describe the information that needs to be displayed in a cell, including the download state.

struct TrackCellModel {
  let title: String
  let duration: String
  let isDownloaded: Bool
}

class TrackCell {
  func configure(model: TrackCellModel) {
    // configure the cell
  }
}

A TrackCellModel provides the cell with the title, duration, and download state. Notice how we’ve already formatted the duration in to a String - We’re going to display it in a text field, so this makes sense. We’ll also benefit from pushing this processing up to the data source, that way we’ll be able to easily test it (more on that soon).

With this setup we go from Domain Models (Track) to Cell Models (TrackCellModel) to UITableViewCells.

Cell Models Data Flow

Where do these cell models get created though? Let’s look at an example flow:

Data Source Flow

The ArtistTableDataSource calls the MusicAPI to get the Tracks. It then asks the DownloadsManager for the download state of each Track, and builds TrackCellModels with this information.

Our ArtistTableDataSource is now completely decoupled from the UI, simply providing an array of TrackCellModels to be displayed. All that’s left is to create a UITableViewDataSource that can create the relevant cell to display each cell model, and configure that cell with the model.

With this setup, the actual UITableViewDataSource is simply a lightweight translation layer from TrackCellModel to UITableViewCell, so I often find it ok to just let the view controller fill this role.

Testing

Now that we have a full table representation that is described by simple model objects, we can easily write tests that verify the content of the table. We’ll simply test the output of the ArtistTableDataSource, before the UITableViewCells would even be created.

Data Source Flow With Testing

A test that verifies that the download state is correctly displayed might look like this:

func testArtistCellDisplaysCorrectDownloadState() {
  let mockMusicAPI = MockMusicAPI()
  mockMusicAPI.returnTracksWithIds([a, b, c, d])
	
  let mockDownloadsManager = MockDownloadsManager()
  mockDownloadsManager.downloadedTrackIds = [a, d]
	
  let dataSource = ArtistTableDataSource(musicAPI: mockMusicAPI,
                                 downloadsManager: mockDownloadsManager)
  dataSource.reload()
	
  // Verify that the table will show tracks ‘a’ and ‘d’ as downloaded
  XCTAssertTrue(dataSource.cellModels[0].isDownloaded)
  XCTAssertFalse(dataSource.cellModels[1].isDownloaded)
  XCTAssertFalse(dataSource.cellModels[2].isDownloaded)
  XCTAssertTrue(dataSource.cellModels[3].isDownloaded)
}

Using this technique we can ensure that a table that is built from multiple sources of information will display the correct data.

We can scale this solution as our table’s data becomes more complex. For instance, we might also add the ability for a user to favourite a track, and we want to show if each track is favourited. The ArtistTableDataSource could also check with the FavouritesManager for each track and set an isFavourited flag on the TrackCellModel which we can then verify in a test.

Staying Humble

The humble object pattern helps us make our code testable by keeping the complexities in our code in the parts of the codebase that we can test, and making the parts that we can’t so simple that they don’t require testing.

There’s no good way for us to test that an actual UITableView is showing the correct UITableViewCells and that all of those cells have the correct content. It’s far easier for us to build an array of TrackCellModels, each which completely describes a cell’s content, and write a test suite that verifies that those models contain the expected configurations for each cell given a range of inputs.

Testable and Untestable

To make our table as humble as possible we need to perform as little calculation in the actual UITableViewCell as possible (the untestable portion of our flow). We’ll achieve this by just performing simple binding of the TrackCellModel’s data to the views of the cell, and pushing all of the hard work to the ArtistTableDataSource.

For example, when you pass a domain model to your cell it might contain a Date which you then convert to a String using a DateFormatter inside of the cell. Using cell models, we can increase the testability of our table by converting the domain models’s Date to a String before giving it our cell model. We can then test that the date formatting is being applied correctly on the cell model, and in the actual cell itself we’ll just bind that string to a UILabel.

Thinking Bigger

By creating a testable dataSource, we can not only test the configuration of individual cells, but the content of the table as a whole. For instance, we might give the user the ability to change the sorting order of the tracks, by most popular, or newest. We could write a test that creates an ArtistTableDataSource connected to a mock MusicAPI, sets the sorting order, and verifies that the TrackCellModels that are produced appear in the correct order.

Here’s how we might test an alphabetically ascending sorting option, for instance:

func testAlphabeticalOrdering {
  self.mockMusicAPI.returnTracksWithTitles([Wonderwall, Hello, Supersonic])
  self.dataSource.sortMode = .alphabeticalAscending
  dataSource.reload()
	
  XCTAssertEqual(dataSource.cellModels[0].title, "Hello")
  XCTAssertEqual(dataSource.cellModels[1].title, "Supersonic")
  XCTAssertEqual(dataSource.cellModels[2].title, "Wonderwall")
}

We can also expand our model to include different cell types. Alongside showing the tracks for a specific artist, we might want to show playlists that feature that artist and related artists. We’ll have a separate cell for each of these. Our model might look like this:

enum ArtistCellModelType {
  case track(TrackCellModel)
  case playlist(PlaylistCellModel)
  case relatedArtist(RelatedArtistCellModel)
}

Our ArtistTableDataSource will now produce an array of ArtistCellModelTypes.

We can now write a test that verifies that specific cells appear in the correct places in the table. The following test verifies that a playlist cell will be shown under the track cells:

func testPlaylistAppearsOnArtistPage {
  self.mockMusicAPI.returnTracksWithIds([a, b, c])
  self.mockPlaylistAPI.returnPlaylistsWithName([Top Hits])
  self.dataSource.reload()
	
  // Adding convenience extensions on ArtistCellModelType makes our tests much easier to read
  XCTAssertTrue(dataSource.cellModels[0].isTrack(withId: "a"))
  XCTAssertTrue(dataSource.cellModels[1].isTrack(withId: "b"))
  XCTAssertTrue(dataSource.cellModels[2].isTrack(withId: "c"))
  XCTAssertTrue(dataSource.cellModels[3].isPlaylist(withName: "Top Hits"))
}

The test was made much more readable due to the extensions on ArtistCellModelType. This type of extension is just for testing so can be declared in your testing target. As your table’s data becomes more complex I find that maintaining a good set of these extensions helps keep the tests short and understandable.

There’s plenty more that we can do too.

I’ve been using the ‘cell model’ approach for my table views and collection views recently and have found it to be a great pattern to increase testability. Having a full representation of the table’s content as model objects makes it easy to write tests that verify that individual cells contain the correct content, and also that the overall structure of the table is as we expect. I hope that you find it useful too!

Next »


« Previous




I'm Steve Barnegren, an iOS developer based in London. If you want to get in touch: