Mastering MVVM With Swift

Testing Your First View Model

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

Choosing the Unit Test Case Class Template

You can name the file whatever you want, but I usually use the name of the type I am testing followed by the suffix Tests. This means I name the file SettingsViewTimeViewModelTests.swift. It's a bit long, but it's very descriptive.

Creating SettingsViewTimeViewModelTests.swift

Click Create to create the file. It's possible that Xcode shows you a dialog when you create your first test case. Xcode offers you to create an Objective-C bridging header. This isn't necessary. Click Don't Create to dismiss the dialog.

There's 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.

SettingsViewTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsViewTimeViewModelTests: XCTestCase {

    ...

}

Remove any existing unit tests and remove the comments from the setUp() and tearDown() methods. I'd like to start with a clean slate.

SettingsViewTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsViewTimeViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

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

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

}

Writing a Unit Test

The first unit test we're going to write tests the text computed property of the SettingsViewTimeViewModel. Revisit the implementation of the SettingsViewTimeViewModel 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 test coverage.

SettingsViewTimeViewModel.swift

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

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

SettingsViewTimeViewModelTests.swift

// MARK: - Tests for Text

func testText_TwelveHour() {
    let viewModel = SettingsViewTimeViewModel(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.

Running Unit Tests of a XCTestCase Subclass

To run one unit test, we click the diamond next to the test we're interested in.

Running Individual Unit Tests

Press Command + U to run the test suite. The diamonds in the gutter of the editor should turn green, indicating that the unit test has passed. Make sure that Destination is set to one of the simulators because Xcode currently doesn't support running a test suite with a physical device as the destination.

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.

SettingsViewTimeViewModelTests.swift

func testText_TwentyFourHour() {
    let viewModel = SettingsViewTimeViewModel(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's looking good.

The unit tests passed.

You've 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 method's name, using an underscore for readability. This is a personal choice that I like because it makes the test methods easier to read. If you name your unit test methods, testText1() and testText2(), you need to read the implementation of the unit test to understand how they differ. Give it a try and see if you like it.

SettingsViewTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsViewTimeViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

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

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

    // 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 SettingsViewTimeViewModel struct.

SettingsViewTimeViewModel.swift

var accessoryType: UITableViewCellAccessoryType {
    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 test coverage.

The name of the first unit test method, testAccessoryType_TwelveHour_TwelveHour(), shows 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.

SettingsViewTimeViewModelTests.swift

// MARK: - Tests for Accessory Type

func testAccessoryType_TwelveHour_TwelveHour() {

}

Despite this complexity, the unit test itself is fairly simple. We create a TimeNotation instance and use it to update the user defaults database. We then create a SettingsViewTimeViewModel instance by passing in another TimeNotation instance.

SettingsViewTimeViewModelTests.swift

// MARK: - Tests for Accessory Type

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

    let viewModel = SettingsViewTimeViewModel(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 UITableViewCellAccessoryType.checkmark. This is reflected in the assertion of the unit test.

SettingsViewTimeViewModelTests.swift

// MARK: - Tests for Accessory Type

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

    let viewModel = SettingsViewTimeViewModel(timeNotation: .twelveHour)

    XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
}

Press Command + U to run the unit tests of the SettingsViewTimeViewModel 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's the best way to learn how to write unit tests. This is what the unit tests should look like when you're finished.

SettingsViewTimeViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsViewTimeViewModelTests: XCTestCase {

    ...

    // MARK: - Tests for Accessory Type

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

        let viewModel = SettingsViewTimeViewModel(timeNotation: .twelveHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
    }

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

        let viewModel = SettingsViewTimeViewModel(timeNotation: .twentyFourHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.none)
    }

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

        let viewModel = SettingsViewTimeViewModel(timeNotation: .twelveHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.none)
    }

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

        let viewModel = SettingsViewTimeViewModel(timeNotation: .twentyFourHour)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
    }

}

Running the Test Suite

Resetting State

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

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

SettingsViewTimeViewModelTests.swift

override func tearDown() {
    super.tearDown()

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

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

Remember that, if the logic contained in the SettingsViewTimeViewModel 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's much easier than unit testing a view controller.

Unit Testing the Other View Models

The unit tests for the SettingsViewUnitsViewModel struct and the SettingsViewTemperatureViewModel struct are very similar to those of the SettingsViewTimeViewModel struct. Pause the video for a moment and try implementing the unit tests for the other two view models. You can find the solution below.

SettingsViewUnitsViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsViewUnitsViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

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

    override func tearDown() {
        super.tearDown()

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

    // MARK: - Tests for Text

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

        XCTAssertEqual(viewModel.text, "Imperial")
    }

    func testText_Metric() {
        let viewModel = SettingsViewUnitsViewModel(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: UserDefaultsKeys.unitsNotation)

        let viewModel = SettingsViewUnitsViewModel(unitsNotation: .imperial)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
    }


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

        let viewModel = SettingsViewUnitsViewModel(unitsNotation: .metric)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.none)
    }

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

        let viewModel = SettingsViewUnitsViewModel(unitsNotation: .imperial)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.none)
    }


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

        let viewModel = SettingsViewUnitsViewModel(unitsNotation: .metric)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
    }

}

SettingsViewTemperatureViewModelTests.swift

import XCTest
@testable import Cloudy

class SettingsViewTemperatureViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

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

    override func tearDown() {
        super.tearDown()

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

    // MARK: - Tests for Text

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

        XCTAssertEqual(viewModel.text, "Fahrenheit")
    }

    func testText_Celsius() {
        let viewModel = SettingsViewTemperatureViewModel(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: UserDefaultsKeys.temperatureNotation)

        let viewModel = SettingsViewTemperatureViewModel(temperatureNotation: .fahrenheit)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
    }

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

        let viewModel = SettingsViewTemperatureViewModel(temperatureNotation: .celsius)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.none)
    }

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

        let viewModel = SettingsViewTemperatureViewModel(temperatureNotation: .fahrenheit)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.none)
    }

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

        let viewModel = SettingsViewTemperatureViewModel(temperatureNotation: .celsius)

        XCTAssertEqual(viewModel.accessoryType, UITableViewCellAccessoryType.checkmark)
    }

}

Press Command + U one more time to run the test 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

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

Next Episode "Using Stubs for Better Unit Tests"

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By