Mastering MVVM With Swift

Testing Your First View Model

In this episode, we unit test the view models of the settings view controller. We start with the SettingsTimeViewModel struct. Create a new file in the Test Cases group and select the Unit Test Case Class template.

Choosing the Unit Test Case Class Template

Name the file SettingsTimeViewModelTests.swift.

Creating SettingsTimeViewModelTests.swift

Click Create to create the file. Xcode offers the option to create an Objective-C bridging header. This isn't necessary, though. Click Don't Create to dismiss the dialog.

There is no need for an Objective-C bridging header.

Importing the Cloudy Module

To access the code from the Cloudy target, we need to add an import statement for the Cloudy module. To make sure we can access internal entities, we prefix the import statement with the testable attribute.

SettingsTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsTimeViewModelTests: XCTestCase {

    ...

}

Remove any existing unit tests and remove the comments from the setUpWithError() and tearDownWithError() methods. I would like to start with a clean slate.

SettingsTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsTimeViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

    override func setUpWithError() throws {

    }

    override func tearDownWithError() throws {

    }

}

Writing a Unit Test

The first unit test we write tests the text computed property of the SettingsTimeViewModel struct. Revisit the implementation of the SettingsTimeViewModel struct if you need to freshen up your memory. Because the text computed property can return two possible values, we need to write two unit tests for complete code coverage.

SettingsTimeViewModel.swift

var text: String {
    switch timeNotation {
    case .twelveHour: return "12 Hour"
    case .twentyFourHour: return "24 Hour"
    }
}

The first unit test is simple. We create a SettingsTimeViewModel object and pass in the model as an argument, a TimeNotation object. To unit test the text computed property, we need to assert that the value text returns is equal to 12 Hour. That's it. Very simple.

SettingsTimeViewModelTests.swift

// MARK: - Tests for Text

func testText_TwelveHour() {
    let viewModel = SettingsTimeViewModel(timeNotation: .twelveHour)

    XCTAssertEqual(viewModel.text, "12 Hour")
}

We have several options to run the unit test we just created. To run every unit test of the test suite, choose Test from Xcode's Product menu or press Command + U. We can also click the diamonds in the gutter of the code editor. If we click the diamond next to the class definition, every unit test of the XCTestCase subclass is run. If we want to run a single unit test, we click the diamond next to the unit test.

Choose a simulator from the list of devices and press Command + U to run the test suite. The diamonds in the gutter of the editor should turn green, indicating that the unit test passed.

The unit test passed.

The second unit test for the text computed property is almost identical. The only changes we need to make are the model we pass to the initializer of the view model and the assertion of the unit test. The result returned by the text computed property should be equal to 24 Hour.

SettingsTimeViewModelTests.swift

func testText_TwentyFourHour() {
    let viewModel = SettingsTimeViewModel(timeNotation: .twentyFourHour)

    XCTAssertEqual(viewModel.text, "24 Hour")
}

Press Command + U one more time to run the test suite to make sure the unit tests pass. That is looking good.

The unit tests passed.

You have probably noticed that I use a specific convention for naming the unit test methods. Each method starts with the word test, which is required, followed by the name of the method or computed property.

Whenever I write multiple unit tests for a single method or computed property, I append a descriptive keyword to the name of the method, using an underscore for readability. This is a personal choice that I like because it makes the unit test methods easier to read. If you name your unit test methods, testText1() and testText2(), you need to read the implementation of the unit tests to understand how they differ. Give it a try and see if you like it.

SettingsTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsTimeViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

    override func setUpWithError() throws {

    }

    override func tearDownWithError() throws {

    }

    // MARK: - Tests for Text

    func testText_TwelveHour() {
        ...
    }

    func testText_TwentyFourHour() {
        ...
    }

}

Writing More Unit Tests

The unit tests for the accessoryType computed property are a bit more complicated because the user defaults database is accessed in the body of the computed property. Take a look at the implementation of the accessoryType computed property of the SettingsTimeViewModel struct.

SettingsTimeViewModel.swift

var accessoryType: UITableViewCell.AccessoryType {
    if UserDefaults.timeNotation == timeNotation {
        return .checkmark
    } else {
        return .none
    }
}

The user's setting, which is stored in the user defaults database, can have one of two values. The model of the view model can also have one of two values. This means we have to write four units tests for complete code coverage.

The name of the first unit test method, testAccessoryType_TwelveHour_TwelveHour(), illustrates what I mean. The first suffix, TwelveHour, hints at the value stored in the user defaults database. The second suffix, TwelveHour, hints at the value of the model of the view model.

SettingsTimeViewModelTests.swift

// MARK: - Tests for Accessory Type

func testAccessoryType_TwelveHour_TwelveHour() {

}

Despite this complexity, the unit test itself is fairly simple. We create a TimeNotation object and use it to update the user defaults database. We then create a SettingsTimeViewModel object using another TimeNotation object.

SettingsTimeViewModelTests.swift

// MARK: - Tests for Accessory Type

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

    let viewModel = SettingsTimeViewModel(timeNotation: .twelveHour)
}

If the user's preference in the user defaults database is twelve hour time notation and the model of the view model is also twelve hour time notation, then the accessory type returned by the accessoryType computed property should be equal to UITableViewCell.AccessoryType.checkmark. This is reflected in the assertion of the unit test.

SettingsTimeViewModelTests.swift

// MARK: - Tests for Accessory Type

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

    let viewModel = SettingsTimeViewModel(timeNotation: .twelveHour)

    XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
}

Press Command + U to run the unit tests of the SettingsTimeViewModel struct to make sure they pass. Open the Test Navigator on the right to bring up an overview of the unit test results.

Bringing Up the Test Navigator

The other three unit tests are permutations of this scenario. Pause the video for a moment and try implementing the other three unit tests yourself. That is the best way to learn how to write unit tests. This is what the unit tests should look like when you are finished.

SettingsTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsTimeViewModelTests: XCTestCase {

    ...

    // MARK: - Tests for Accessory Type

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

        let viewModel = SettingsTimeViewModel(timeNotation: .twelveHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
    }

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

        let viewModel = SettingsTimeViewModel(timeNotation: .twentyFourHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.none)
    }

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

        let viewModel = SettingsTimeViewModel(timeNotation: .twelveHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.none)
    }

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

        let viewModel = SettingsTimeViewModel(timeNotation: .twentyFourHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
    }

}

Running the Test Suite

Resetting State

Because we modify a value of the user defaults database in the unit tests, it is important that we reset that state after each unit test. We can do this in the tearDownWithError() method of the XCTestCase subclass.

We remove the object for the key that is used to store the value of the time notation. Even though it isn't strictly necessary for these unit tests, it is a good practice to always reset the state you set or modify in a unit test.

SettingsTimeViewModelTests.swift

override func tearDownWithError() throws {
    // Reset User Defaults
    UserDefaults.standard.removeObject(forKey: "timeNotation")
}

If you are not familiar with unit testing, the setUpWithError() method is invoked before a unit test is run and the tearDownWithError() method is invoked after a unit test is run. In other words, the setUpWithError() and tearDownWithError() methods are invoked six times because we have written six unit tests.

Remember that, if the logic contained in the SettingsTimeViewModel struct was still in the SettingsViewController class, the unit tests would be much more complex. I hope you can see that unit testing a view model isn't difficult. It is much easier than unit testing a view controller.

Unit Testing the Other View Models

The unit tests for the SettingsUnitsViewModel and SettingsTemperatureViewModel structs are very similar to those of the SettingsTimeViewModel struct. Pause the video for a moment and try implementing the unit tests for the other view models of the SettingsViewController class. The finished project of this episode includes the solution.

SettingsUnitsViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsUnitsViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

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

    override func tearDown() {
        super.tearDown()

        // Reset User Defaults
        UserDefaults.standard.removeObject(forKey: "unitsNotation")
    }

    // MARK: - Tests for Text

    func testText_Imperial() {
        let viewModel = SettingsUnitsViewModel(unitsNotation: .imperial)

        XCTAssertEqual(viewModel.text, "Imperial")
    }

    func testText_Metric() {
        let viewModel = SettingsUnitsViewModel(unitsNotation: .metric)

        XCTAssertEqual(viewModel.text, "Metric")
    }

    // MARK: - Tests for Accessory Type

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

        let viewModel = SettingsUnitsViewModel(unitsNotation: .imperial)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
    }


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

        let viewModel = SettingsUnitsViewModel(unitsNotation: .metric)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.none)
    }

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

        let viewModel = SettingsUnitsViewModel(unitsNotation: .imperial)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.none)
    }


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

        let viewModel = SettingsUnitsViewModel(unitsNotation: .metric)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
    }

}

SettingsTemperatureViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsTemperatureViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

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

    override func tearDown() {
        super.tearDown()

        // Reset User Defaults
        UserDefaults.standard.removeObject(forKey: "temperatureNotation")
    }

    // MARK: - Tests for Text

    func testText_Fahrenheit() {
        let viewModel = SettingsTemperatureViewModel(temperatureNotation: .fahrenheit)

        XCTAssertEqual(viewModel.text, "Fahrenheit")
    }

    func testText_Celsius() {
        let viewModel = SettingsTemperatureViewModel(temperatureNotation: .celsius)

        XCTAssertEqual(viewModel.text, "Celsius")
    }

    // MARK: - Tests for Accessory Type

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

        let viewModel = SettingsTemperatureViewModel(temperatureNotation: .fahrenheit)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
    }

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

        let viewModel = SettingsTemperatureViewModel(temperatureNotation: .celsius)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.none)
    }

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

        let viewModel = SettingsTemperatureViewModel(temperatureNotation: .fahrenheit)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.none)
    }

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

        let viewModel = SettingsTemperatureViewModel(temperatureNotation: .celsius)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCell.AccessoryType.checkmark)
    }

}

Press Command + U one more time to run the suite of unit tests. You can see the results in the Test Navigator or in the Report Navigator.

Inspecting the Results In the Test Navigator

Inspecting the Results In the Report Navigator

What's Next?

In the next episode, we write unit tests for the DayViewModel struct.

Next Episode "Using Stubs for Better Unit Tests"