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.
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 Track
s 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 Track
s 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 UITableViewCell
s.
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.
Unfortunately our Track
model now isn’t going to fully describe everything that a TrackTableViewCell
needs to know. We get the Track
s 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:
MusicAPI
returns the correct tracks.DownloadsManager
reports the correct downloaded state for a track.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.
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 UITableViewCell
s.
Where do these cell models get created though? Let’s look at an example flow:
The ArtistTableDataSource
calls the MusicAPI
to get the Track
s. It then asks the DownloadsManager
for the download state of each Track
, and builds TrackCellModel
s with this information.
Our ArtistTableDataSource
is now completely decoupled from the UI, simply providing an array of TrackCellModel
s 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.
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 UITableViewCell
s would even be created.
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.
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 UITableViewCell
s and that all of those cells have the correct content. It’s far easier for us to build an array of TrackCellModel
s, 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.
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
.
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 ArtistCellModelType
s.
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.
MockFavouritesManager
to our datasource, we can mark some track IDs as favourited and verify that they appear first in the array.ArtistTableDataSource
with a User
that has an age
where this feature is enabled. We can then tell our MockMusicAPI
to return tracks with the containsExplicitLyrics
flag set to true
, and verify that our data source doesn’t include these tracks in the table.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!