Ayaka testing cover image

Testing in Swift: Protocols & View Models

Introduction

My name is Ayaka, and I work on Workflow, an automation app for iOS.

Testing is something that usually gets put in the back burner for a variety of reasons. It’s thought of being too difficult or time consuming, and some developers may be too lazy to include tests.

I’d like to focus it on two different techniques to make testing easier without diminishing the quality of code: view models and protocols.

The App (2:57)

To demostrate these techniques, I’ll use a simple weather app that shows the location, and the current temperature.

The Code (3:29)

AppDelegate:

Instead of making a network call, the data is loaded from a local JSON file.

let json = loadJSONFixture(for: "observation")
let observation = Observation(dictionary: json)!

WeatherViewController:

In viewDidLoad, the gradient layer is set, along with the city’s label to display observations, location and city name. The weather’s label is also set, along with the constraints.

override func viewDidLoad() {
    super.viewDidLoad()
    view.layer.addSublayer(gradientLayer)

    cityLabel.text = displayable.location
    weatherLabel.text = displayable.weather
    temperatureLabel.text = displayable.formattedTemperatureCelcius

    containerView.addArrangedSubview(cityLabel)
    containerView.addArrangedSubview(weatherLabel)
    containerView.addArrangedSubview(temperatureLabel)
    view.addSubview(containerView)

    NSLayoutConstraint.activateConstraints([
        containerView.centerYAnchor.constraintEqualToAnchor(view.centerYAnchor),
        containerView.leadingAnchor.constraintEqualToAnchor(view.leadingAnchor),
        containerView.trailingAnchor.constraintEqualToAnchor(view.trailingAnchor),
    ])
}

Models

There’s an Observation struct which sets the display location, observation location, weather, temperature in Celsius and Fahrenheit. This can be initialized with a dictionary.


import Foundation

struct Observation: Equatable {
    let displayLocation: Location
    let observationLocation: Location
    let weather: String
    let temperatureCelcius: Double
    let temperatureFahrenheit: Double
}

func ==(lhs: Observation, rhs: Observation) -> Bool {
    return lhs.displayLocation == rhs.displayLocation &&
    lhs.observationLocation == rhs.observationLocation &&
    lhs.weather == rhs.weather &&
    lhs.temperatureCelcius == rhs.temperatureCelcius &&
    lhs.temperatureFahrenheit == rhs.temperatureFahrenheit
}

extension Observation: JSONDeserializable {
    
    init?(dictionary: JSONDictionary) {
        guard
            let locationDictionary = dictionary["display_location"] as? JSONDictionary,
            let displayLocation = Location(dictionary: locationDictionary),
            let observationDictionary = dictionary["observation_location"] as? JSONDictionary,
            let observationLocation = Location(dictionary: observationDictionary),
            let weather = dictionary["weather"] as? String,
            let temperatureCelcius = dictionary["temp_c"] as? Double,
            let temperatureFahrenheit = dictionary["temp_f"] as? Double
        else { return nil }
        self.displayLocation = displayLocation
        self.observationLocation = observationLocation
        self.weather = weather
        self.temperatureCelcius = temperatureCelcius
        self.temperatureFahrenheit = temperatureFahrenheit
    }
}

extension Observation: WeatherViewControllerDisplayable {
    var location: String {
        return displayLocation.fullCityName
    }
}

The Tests (6:04)

Location (6:10)

Get more development news like this

The LocationTests are fairly standard:

import XCTest
@testable import Weather

class LocationTests: XCTestCase {

    func testInitialization() {
        let json = loadJSONFixture(for: "location")
        let location = Location(dictionary: json)

        XCTAssertEqual("San Francisco, CA", location?.fullCityName)
        XCTAssertEqual("San Francisco", location?.city)
        XCTAssertEqual("CA", location?.state)
        XCTAssertEqual("US", location?.country)
        XCTAssertEqual("US", location?.countryISO3166)
        XCTAssertEqual("37.77500916", location?.latitude)
        XCTAssertEqual("-122.41825867", location?.longitude)
        XCTAssertEqual("94101", location?.zipCode)
        XCTAssertEqual("47.00000000", location?.elevation)
    }
}

A location is pulled from the local JSON file, and it ensures that all the properties are set.

Observation (6:30)

This is the same for ObservationTests:


import XCTest
@testable import Weather

class ObservationTests: XCTestCase {

    func testInitialization() {
        let json = loadJSONFixture(for: "observation")
        let observation = Observation(dictionary: json)

        XCTAssertEqual("San Francisco, CA", observation!.displayLocation.fullCityName)
        XCTAssertEqual("North Mission (Valencia and Market), San Francisco, California", observation!.observationLocation.fullCityName)
        XCTAssertEqual("Partly Cloudy", observation!.weather)

        // TODO: File radar about XCTAssertEqualWithAccuracy supporting optionals.
        XCTAssertEqualWithAccuracy(56.1, observation!.temperatureFahrenheit, accuracy: 0.001)
        XCTAssertEqualWithAccuracy(13.4, observation!.temperatureCelcius, accuracy: 0.001)
    }

    func testWeatherViewControllerDisplayable() {
        let json = loadJSONFixture(for: "observation")
        let observation = Observation(dictionary: json)

        XCTAssertEqual("San Francisco, CA", observation?.location)
        XCTAssertEqual("Partly Cloudy", observation?.weather)
        XCTAssertEqualWithAccuracy(13.4, observation!.temperatureCelcius, accuracy: 0.001)
    }
}

View Controller (6:41)

An Observation struct is created because the WeatherViewController expects one in the initializer.

class WeatherViewControllerTests: XCTestCase {

    struct TestDisplayable: WeatherViewControllerDisplayable {
        let location = "Barcelona"
        let weather = "Very Hot"
        let temperatureCelcius: Double = 9001
    }

    func testInitialization() {
        let displayable = TestDisplayable()
        let viewController = WeatherViewController(with: displayable)

        XCTAssertEqual(displayable, viewController.displayable)
    }

    func testWeatherViewControllerDisplayable() {
        let displayable = TestDisplayable()
        XCTAssertEqual("9001°", displayable.formattedTemperatureCelcius)

Then the test ensures that the observation property is set.

Testing Other Attributes Likely to Fail (6:58)

Why are there not tests for attributes likely to fail, such as the formatting of the temperature or city label?

The reason is because the labels weatherLabel, and temperatureLabel are private by design to ensure immutability.

As an alternative, UI testing can be implemented using KIF. But that will be slow, and these things are better unit tested. Another factor to consider is WeatherViewController is coupled with the Observation struct.

Notice that it gets created with an Observation, but this is specific to the API being used. If another API is used, WeatherViewController and the initializer must be rewritten.

We Can Do Better (8:35)

To improve upon this, instead of Observation being in the ViewController, put it in the WeatherViewModel.

import Foundation

struct WeatherViewModel: Equatable {
    let observation: Observation
    
    let city: String
    let weather: String
    let temperature: String
    
    init(with observation: Observation) {
        self.observation = observation
        
        city = observation.displayLocation.fullCityName
        weather = observation.weather
        temperature = String(format: "%.0f", observation.temperatureCelcius)
    }
}
func ==(lhs: WeatherViewModel, rhs:WeatherViewModel) -> Bool {
    return lhs.city == rhs.city &&
    lhs.weather == rhs.weather &&
    lhs.temperature == rhs.temperature
}

In WeatherViewController, instead of initializing with the observation struct, initialize it with a view model:

let viewModel: WeatherViewModel

init(with viewModel: WeatherViewModel) {
    self.viewModel = viewModel
    super.init(nibName: nil, bundle: nil)
}

In the viewDidLoad, instead of formatting, grab the values from the view model and set it:

cityLabel.text = viewModel.city
weatherLabel.text = viewModel.weather
temperatureLabel.text = viewModel.temperature

The WeatherViewModelTests looks similar to before, but an Observation struct is created, along with a view model to ensure that the properties are set to their respective values:

import XCTest
@testable import Weather

class WeatherViewModelTests: XCTestCase {

    func testInitialization() {
        let json = loadJSONFixture(for: "observation")
        let observation = Observation(dictionary: json)!
        let viewModel = WeatherViewModel(with: observation)

        XCTAssertEqual("San Francisco, CA", viewModel.city)
        XCTAssertEqual("Partly Cloudy", viewModel.weather)
        XCTAssertEqual("56°", viewModel.temperature)
    }
}

In WeatherViewControllerTests, load the JSON to create an observation, then create the view model for creating a WeatherViewController:

let json = loadJSONFixture(for: "observation")
let observation = Observation(dictionary: json)!
let viewModel = WeatherViewModel(with: observation)

let viewController = WeatherViewController(with: viewModel)

XCTAssertEqual(viewModel, viewController.viewModel)

The problem with not testing each of those labels are now handled by the view model. Moreover, the issues with labels being private is solved.

It appears that the issue with WeatherViewController being coupled to Observation (making it specific to Weather Underground’s API) is fixed, but this may not be good enough.

Issues (11:54)

1. This is an annoying way to test WeatherViewController (11:58)

With this setup, it’s onerous to test WeatherViewController. One must load the JSON, create an observation, along with a view model from it, then use the view model in WeatherViewController’s initializer to test the following:

XCTAssertEqual(viewModel, viewController.viewModel)

2. Have we really decoupled WeatherViewController and Observation? (12:26)

With this approach, WeatherViewController and Observation have not been decoupled, rather, another layer of indirection was added.

WeatherViewModel is now initialized with Observation. Instead of coupling Observation with the view controller, it’s coupled with the view model of Observation.

We can do even better! (14:33)

Here, the protocol WeatherViewControllerDisplayable requires a location, weather, and temperature in Celsius as a double.

WeatherViewControllerDisplayable:

protocol WeatherViewControllerDisplayable: Equatable {
    var location: String { get }
    var weather: String { get }
    var temperatureFahrenheit: Double { get }
}

func ==<T: WeatherViewControllerDisplayable>(lhs: T, rhs: T) -> Bool {
    return lhs.location == rhs.location &&
    lhs.weather == rhs.weather &&
    lhs.temperatureFahrenheit == rhs.temperatureFahrenheit
}

In the WeatherViewControllerDisplayable extension, a computed variable formats the temperature in Celsius as a string:

extension WeatherViewControllerDisplayable {
    var formattedTemperatureFahrenheit: String {
        return String(format: "%.0f°", temperatureFahrenheit)
    }
}

Notice how WeatherViewController changed.

It’s a generic type Displayable, which conforms to WeatherViewControllerDisplayable.

 let displayable: Displayable

The initializer:

init(with displayable: Displayable) {
    self.displayable = displayable
    super.init(nibName: nil, bundle: nil)
}

Here is the resulting test for WeatherViewController:

It now has a sub-struct that conforms to weather view controller displayable. The location, weather, and temperature in Celsius is set.

struct TestDisplayable: WeatherViewControllerDisplayable {
    let location = "Washington DC"
    let weather = "Omg Too Hot"
    let temperatureFahrenheit: Double = 9001
}

Create an instance of this, and ensure that the formatted temperature Celsius is returning the correctly-formatted value.

func testWeatherViewControllerDisplayable() {
    let displayable = TestDisplayable()
    XCTAssertEqual("9001°", displayable.formattedTemperatureFahrenheit)
}

Instead of having to go between the long JSON file and making sure San Francisco, California is the desired location. It can be looked up, and know exactly what to test against.

This makes it a lot easier to know what tests to write.

Review (20:55)

  • View models can help keep view controllers slim.
    • Treat view controllers like UIViews, and try to extract as many things into the view model.
  • View models make it easier to test some of the UI, without UI tests.
  • Protocols allow for actual decoupling.
  • Protocols make it easier to write and change tests.
    • Protocols make tests more maintainable.

All the code and slides are on github.com.

Next Up: Keep on testing, all the way to your Realm apps

General link arrow white

About the content

This talk was delivered live in July 2016 at CMD+U Conference. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Ayaka Nonaka

Ayaka leads the iOS team at Venmo, where they write only Swift these days. She’s been doing iOS development since iOS 4 and loves writing Swift while listening to Taylor Swift. In the past, she’s given talks on NLP in Swift, Swift Scripting, and rewriting the Venmo app in Swift. She was born in Tokyo and was super excited to give her first talk there! 宜しくお願いします。