Mastering MVVM With Swift

Using Stubs for Better Unit Tests

Testing the DayViewViewModel struct isn't very different from testing the view models of the SettingsViewController class. The only tricky aspect is instantiating a DayViewViewModel instance in a unit test.

To instantiate a DayViewViewModel instance, we need a model. Should we fetch weather data from the Dark Sky API during a test run? The answer is a resounding "no". To guarantee that the unit tests for the DayViewViewModel struct are fast and reliable, we need stubs.

The idea is simple. We fetch a response from the Dark Sky API, save it in the unit testing bundle, and load the response when we run the unit tests for the view model. Let me show you how this works.

Adding Stub Data

I've already saved a response from the Dark Sky API to my desktop. This is nothing more than a plain text file with JSON data. Before we can use it in the test case, we add the file to the unit testing bundle. The JSON file is included in the finished project of this episode. Drag it in the Stubs group of the CloudyTests target.

Adding Stub Data

Make sure that Copy items if needed is checked and that the file is only added to the CloudyTests target.

Adding Stub Data to CloudyTests Target

Adding Stub Data to CloudyTests Target

Loading Stub Data

Because we'll use the stub data in multiple test cases, we first create a helper method to load the stub data from the unit testing bundle. Create a new file in the Extensions group of the unit testing bundle and name it XCTestCase.swift.

Creating an Extension for XCTestCase

Replace the import statement for Foundation with an import statement for XCTest and define an extension for the XCTestCase class.

XCTestCase.swift

import XCTest

extension XCTestCase {

}

Name the helper method loadStubFromBundle(withName:extension:).

XCTestCase.swift

func loadStubFromBundle(withName name: String, extension: String) -> Data {

}

The method accepts two parameters:

  • the name of a file
  • and the extension of a file

In loadStubFromBundle(withName:extension:), we fetch a reference to the unit testing bundle, ask it for the URL of the file we're interested in, and use the URL to instantiate a Data instance.

XCTestCase.swift

func loadStubFromBundle(withName name: String, extension: String) -> Data {
    let bundle = Bundle(for: classForCoder)
    let url = bundle.url(forResource: name, withExtension: `extension`)

    return try! Data(contentsOf: url!)
}

Notice that we force unwrap the url optional and, Heaven forbid, use the try keyword with an exclamation mark. This is something I only ever do when writing unit tests. You have to understand that we're only interested in the results of the unit tests. If anything else goes wrong, we made a silly mistake, which we need to fix. In other words, I'm not interested in error handling or safety when writing and running unit tests. If something goes wrong, the unit tests fail anyway.

Unit Testing the Day View View Model

We can now create the test case for the DayViewViewModel struct. Create a new test case and name the file DayViewViewModelTests.swift. We start by adding an import statement for the Cloudy module. Don't forget to prefix the import statement with the testable attribute.

Creating DayViewViewModelTests.swift

DayViewViewModelTests.swift

import XCTest
@testable import Cloudy

class DayViewViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

}

To simplify the unit tests, we won't be instantiating a view model in each of the unit tests. Instead, we create a view model, the view model we use for testing, in the setUp() method. Let me show you how that works and what the benefits are.

We first define a property for the view model. This means every unit test will have access to a fully initialized view model, ready for testing.

DayViewViewModelTests.swift

import XCTest
@testable import Cloudy

class DayViewViewModelTests: XCTestCase {

    // MARK: - Properties

    var viewModel: DayViewViewModel!

    // MARK: - Set Up & Tear Down

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

}

Notice that the type of the property is an implicitly unwrapped optional. This is dangerous, but remember that we don't care if the test suite crashes and burns. If that happens, it means that we made a mistake we need to fix. This is really important to understand. When we're running the unit tests, we're interested in the test results. We very often use shortcuts for convenience to improve the clarity and the readability of the unit tests. That will become clear in a moment.

In the setUp() method, we invoke the loadStubFromBundle(withName:extension:) helper method to load the contents of the stub we added earlier and we use the Data object to instantiate a WeatherData instance. The model is used to create the DayViewViewModel instance we're going to use in each of the unit tests.

DayViewViewModelTests.swift

override func setUp() {
    super.setUp()

    // Load Stub
    let data = loadStubFromBundle(withName: "darksky", extension: "json")
    let weatherData: WeatherData = try! JSONDecoder.decode(data: data)

    // Initialize View Model
    viewModel = DayViewViewModel(weatherData: weatherData)
}

The first unit test is as simple as unit tests get. We test the date computed property of the DayViewViewModel struct. We assert that the value of the date computed property is equal to the value we expect.

DayViewViewModelTests.swift

// MARK: - Tests for Date

func testDate() {
    XCTAssertEqual(viewModel.date, "Tue, July 11")
}

We can keep the unit test this simple because we control the stub data. If we were to fetch a response from the Dark Sky API, we wouldn't have a clue what would come back. It would be slow, asynchronous, and prone to all kinds of issues.

The second unit test we write is for the time computed property of the DayViewViewModel struct. Because the value of the time computed property depends on the user's preference, stored in the user defaults database, we have two unit tests to write.

DayViewViewModelTests.swift

// MARK: - Tests for Time

func testTime_TwelveHour() {

}

func testTime_TwentyFourHour() {

}

The body of the first unit test looks very similar to some of the unit tests we wrote in the previous episode. We set the time notation setting in the user defaults database and assert that the value of the time computed property is equal to the value we expect. Let me repeat that we can only do this because we know the contents of the stub data and, as a result, the model the view model manages.

DayViewViewModelTests.swift

func testTime_TwelveHour() {
    let timeNotation: TimeNotation = .twelveHour
    UserDefaults.standard.set(timeNotation.rawValue, forKey: UserDefaultsKeys.timeNotation)

    XCTAssertEqual(viewModel.time, "01:57 PM")
}

The second unit test for the time computed property is very similar. Only the value we set in the user defaults database is different.

DayViewViewModelTests.swift

func testTime_TwentyFourHour() {
    let timeNotation: TimeNotation = .twentyFourHour
    UserDefaults.standard.set(timeNotation.rawValue, forKey: UserDefaultsKeys.timeNotation)

    XCTAssertEqual(viewModel.time, "13:57")
}

The remaining unit tests for the DayViewViewModel struct follow the same pattern. Pause the video for a moment and give them a try. I have to warn you, though, the unit test for the image computed property is a bit trickier. But you can do this. You can find the remaining unit tests in the finished project of this episode.

DayViewViewModelTests.swift

// MARK: - Tests for Summary

func testSummary() {
    XCTAssertEqual(viewModel.summary, "Clear")
}

// MARK: - Tests for Temperature

func testTemperature_Fahrenheit() {
    let temperatureNotation: TemperatureNotation = .fahrenheit
    UserDefaults.standard.set(temperatureNotation.rawValue, forKey: UserDefaultsKeys.temperatureNotation)

    XCTAssertEqual(viewModel.temperature, "44.5 °F")
}

func testTemperature_Celsius() {
    let temperatureNotation: TemperatureNotation = .celsius
    UserDefaults.standard.set(temperatureNotation.rawValue, forKey: UserDefaultsKeys.temperatureNotation)

    XCTAssertEqual(viewModel.temperature, "6.9 °C")
}

// MARK: - Tests for Wind Speed

func testWindSpeed_Imperial() {
    let unitsNotation: UnitsNotation = .imperial
    UserDefaults.standard.set(unitsNotation.rawValue, forKey: UserDefaultsKeys.unitsNotation)

    XCTAssertEqual(viewModel.windSpeed, "6 MPH")
}

func testWindSpeed_Metric() {
    let unitsNotation: UnitsNotation = .metric
    UserDefaults.standard.set(unitsNotation.rawValue, forKey: UserDefaultsKeys.unitsNotation)

    print(viewModel.windSpeed)

    XCTAssertEqual(viewModel.windSpeed, "10 KPH")
}

// MARK: - Tests for Image

func testImage() {
    let viewModelImage = viewModel.image
    let imageDataViewModel = UIImagePNGRepresentation(viewModelImage!)!
    let imageDataReference = UIImagePNGRepresentation(UIImage(named: "clear-day")!)!

    XCTAssertNotNil(viewModelImage)
    XCTAssertEqual(viewModelImage!.size.width, 236.0)
    XCTAssertEqual(viewModelImage!.size.height, 236.0)
    XCTAssertEqual(imageDataViewModel, imageDataReference)
}

The unit test for the image computed property is slightly different. Comparing images isn't straightforward. We first make an assertion that the value of the image computed property isn't nil because it returns a UIImage?.

DayViewViewModelTests.swift

XCTAssertNotNil(viewModelImage)

We then convert the image to a Data object and compare it to a reference image, loaded from the application bundle. You can go as far as you like. For example, I've also added assertions for the dimensions of the image. This isn't critical for this application, but it shows you what's possible.

DayViewViewModelTests.swift

XCTAssertEqual(viewModelImage!.size.width, 236.0)
XCTAssertEqual(viewModelImage!.size.height, 236.0)
XCTAssertEqual(imageDataViewModel, imageDataReference)

Before we run the test suite, we need to tie up some loose ends. In the tearDown() method, we reset the state we set in the unit tests.

DayViewViewModelTests.swift

override func tearDown() {
    super.tearDown()

    // Reset User Defaults
    UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.timeNotation)
    UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.unitsNotation)
    UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.temperatureNotation)
}

Press Command + U to run the test suite to make sure the unit tests for the DayViewViewModel struct pass.

Running the Test Suite

In the next episode, we unit test the view models for the WeekViewController class.

Next Episode "A Few More Unit Tests"

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By