Making Views More Testable & Reusable

Posted 7/22/2018.

Apple's documentation on Model View Controller (MVC) says the following about views:

A major purpose of view objects is to display data from the application’s model objects and to enable the editing of that data. Despite this, view objects are typically decoupled from model objects in an MVC application.

In practice, it is not uncommon for view subclasses to be coupled with model objects. It's typical to see User and UserTableViewCell or Project and ProjectHeaderView classes.

In this tutorial, we'll see how we can use protocols to make custom view subclasses more generic. The benefit is that they'll be easier to test and reuse, and our code will be easier to read and maintain.

Starting Point

Let's start with the example of a User model and a UserTableViewCell view. The UITableViewCell subclass might look like this:

import UIKit

class UserTableViewCell: UITableViewCell {

    @IBOutlet weak var nameLabel: UILabel!
    @IBOutlet weak var locationLabel: UILabel!
    @IBOutlet weak var profileImageView: UIImageView!

    func configure(with user: User) {
        nameLabel.text = [user.firstName, user.lastName].joined(separator: " ")
        locationLabel.text = user.location
        guard let profileImageURL = user.profileImageURL else { return }
        loadImage(withURL: profileImageURL)
    }

    func loadImage(withURL url: String) {
        // loads image into UIImageView
    }

}

This cell has two labels and an image view, displaying the name, location, and profile image of a user.

The corresponding User model could be:

class User {

    let id: String
    let firstName: String
    let lastName: String
    let email: String
    let location: String
    let profileImageURL: String?
    let birthday: Date

    init(id: String, firstName: String, lastName: String, email: String, location: String, profileImageURL: String?, birthday: Date) {
        self.id = id
        self.firstName = firstName
        self.lastName = lastName
        self.email = email
        self.location = location
        self.profileImageURL = profileImageURL
        self.birthday = birthday
    }

}

The first thing you probably noticed is that this User model contains more information than the view needs. The properties id, email, and birthday are not used in the view. This makes the view more cumbersome to test than it needs to be. To test the configure method, we need to pass in instances of User, which means we need to pass in unnecessary IDs, email addresses, and birthdays.

This extra code also makes the code more difficult to read and understand. Really all the view needs for the first label is a "name", but our User model has separate properties for first and last names. The view then becomes responsible for determining how the full name of a User is displayed when it shouldn't be.

Using Protocols

To improve the situation we can add the following protocol:

protocol UserCellItem {

    var name: String { get }
    var location: String { get }
    var imageURL: String? { get }

}

User can conform as follows:

extension User: UserCellItem {

    var name: String { return [firstName, lastName].joined(separator: " ") }
    var imageURL: String? { return profileImageURL }

}

Now in UserTableViewCell, the configure method can be replaced with:

func configure(with item: UserCellItem) {
    nameLabel.text = item.name
    locationLabel.text = item.location
    guard let profileImageURL = item.imageURL else { return }
    loadImage(withURL: profileImageURL)
}

Much cleaner. The cell no longer needs to care about a User model and its extra properties, and it doesn't need to know how a "name" is derived from a User.

But why is this cell tied to the idea of a "user" at all? It's easy to imagine the following Team model displayed by the same cell:

class Team {

    let id: String
    let name: String
    let location: String
    let profileImageURL: String?

    init(id: String, name: String, location: String, profileImageURL: String?) {
        self.id = id
        self.name = name
        self.location = location
        self.profileImageURL = profileImageURL
    }

}

extension Team: UserCellItem {

    var imageURL: String? { return profileImageURL }

}

You probably see where this is going. Our cell class is really a basic UITableViewCell that displays a title, detail, and image. Our protocol should really look like this:

protocol BasicCellItem {

    var title: String { get }
    var detail: String { get }
    var imageURL: String? { get }

}

With some renaming, our cell class becomes:

class BasicTableViewCell: UITableViewCell {

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var detailLabel: UILabel!
    @IBOutlet weak var profileImageView: UIImageView!

    func configure(with item: BasicCellItem) {
        titleLabel.text = item.title
        detailLabel.text = item.detail
        guard let imageURL = item.imageURL else { return }
        loadImage(withURL: imageURL)
    }

    func loadImage(withURL url: String) {
        // loads image into UIImageView
    }

}

Testing this class is now very easy:

struct BasicItem: BasicCellItem {

    let title: String
    let detail: String
    let imageURL: String?

}

class BasicTableViewCellTests: XCTestCase {
    
    func testConfigure() {
        let bundle = Bundle(for: BasicTableViewCell.self)
        guard let cell = bundle.loadNibNamed("BasicTableViewCell", owner: nil)?.first as? BasicTableViewCell else { return XCTFail() }
        let item = BasicItem(title: "Test", detail: "Description", imageURL: nil)
        cell.configure(with: item)
        XCTAssertEqual(cell.titleLabel.text, "Test")
        XCTAssertEqual(cell.detailLabel.text, "Description")
    }
    
}

We have a simple struct conforming to BasicCellItem with the sole purpose of testing the view. We don't need to worry about generating values for extra User, Team, or any other model's properties. In fact, because this view is now no longer coupled with a model, we could extract it into a separate framework and reuse it in other apps with very different models.


When starting a new app, it can be easy to create more specialized views than we need. We may not know exactly which information the view will eventually need to display or how similar it will be to views displaying other data. So we may default to a 1:1 coupling of model to view.

In this tutorial, we saw the benefits of using protocols to make views more generic. They become reusable and easier to test. Our code also becomes more modular. When views are tightly coupled with models, we can't extract them into reusable frameworks. Once freed from the model layer, we can use them in other apps and share them with the open source community.