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)
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.
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.