Mastering MVVM With Swift

Using Stubs for Better Unit Tests

Download Your Free Copy of
The Missing Manual
for Swift Development

The Guide I Wish I Had When I Started Out

Join 20,000+ Developers Learning About Swift Development

Download Your Free Copy

Unit testing the DayViewModel struct isn't very different from unit testing the view models of the SettingsViewController class. The only tricky aspect is creating a DayViewModel object in a unit test.

To create a DayViewModel object, we need a model. Should we fetch weather data from the weather API while we execute the test suite? The answer is no. To guarantee that the unit tests for the DayViewModel struct are fast and reliable, we need to make use of stubs.

The idea is simple. We fetch a response from the weather API, add it to the unit testing bundle, and load the response when the unit tests for the view model are executed. Let me show you how this works.

Adding a Stub

I already saved a response from the weather API to my desktop. This is nothing more than a JSON file. Before we can use it in the test case, we add the JSON 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. Make sure that Copy items if needed is checked and that the file is only added to the CloudyTests target.

Adding a Stub to CloudyTests Target

Adding a Stub to CloudyTests Target

Loading a Stub

Because we plan to use the stub in multiple test cases, we start by creating a helper method to load the stub from the unit testing bundle. Add a Swift file to the Extensions group of the unit testing bundle and name it XCTestCase.swift.

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 {

}

Define a method with name loadStub(name:extension:).

XCTestCase.swift

// MARK: - Helper Methods

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

}

The method defines two parameters:

  • the name of the file
  • the extension of the file

We first obtain a reference to the unit testing bundle. We create an instance of the Bundle class by invoking the init(for:) initializer, passing in the type of the XCTestCase instance. We then ask the unit testing bundle for the URL of the stub, passing in the name and extension of the file. The URL is used to create a Data object.

XCTestCase.swift

// MARK: - Helper Methods

func loadStub(name: String, extension: String) -> Data {
    // Obtain Reference to Bundle
    let bundle = Bundle(for: type(of: self))

    // Ask Bundle for URL of Stub
    let url = bundle.url(forResource: name, withExtension: extension)

    // Use URL to Create Data Object
    return try! Data(contentsOf: url!)
}

Notice that we forced unwrap url and use the try keyword with an exclamation mark. This is something I only ever do when writing unit tests. It is important to understand that we are only interested in the results of the unit tests. If anything else goes wrong, we made a mistake we need to fix. In other words, I am 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 DayViewModel struct. Create an XCTestCase subclass and name the file DayViewModelTests.swift. We start by adding an import statement for the Cloudy module and prefix it with the testable attribute.

DayViewModelTests.swift

import XCTest
@testable import Cloudy

class DayViewModelTests: XCTestCase {

    // MARK: - Set Up & Tear Down

    override func setUpWithError() throws {

    }

    override func tearDownWithError() throws {

    }

}

To simplify the unit tests, we won't be creating a DayViewModel object in each unit test. Instead, we create a DayViewModel object in the setUpWithError() method. Let me show you how that works and what the benefits are.

We first define a property for the DayViewModel object. This means every unit test has access to the DayViewModel object.

DayViewModelTests.swift

import XCTest
@testable import Cloudy

class DayViewModelTests: XCTestCase {

    // MARK: - Properties

    var viewModel: DayViewModel!

    // MARK: - Set Up & Tear Down

    override func setUpWithError() throws {

    }

    override func tearDownWithError() throws {

    }

}

Notice that 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 we made a mistake we need to fix. This is really important to understand. When we execute the test suite, we are interested in the test results. We often use shortcuts for convenience to improve the clarity and the readability of the unit tests. This becomes clear in a moment.

In the setUpWithError() method, we invoke loadStub(name:extension:) to load the contents of the stub we added earlier. We create a JSONDecoder instance, set its dateDecodingStrategy property to secondsSince1970, and use it to create a WeatherData object. The WeatherData object is used to create the DayViewModel object we use in the unit tests.

DayViewModelTests.swift

override func setUpWithError() throws {
    // Load Stub
    let data = loadStub(name: "weather", extension: "json")

    // Create JSON Decoder
    let decoder = JSONDecoder()

    // Configure JSON Decoder
    decoder.dateDecodingStrategy = .secondsSince1970

    // Decode JSON
    let weatherData = try decoder.decode(WeatherData.self, from: data)

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

With the DayViewModel object ready to use, it is time to write some unit tests. The first unit test is as simple as unit tests get. We test the date computed property of the DayViewModel struct. We assert that the value of the date computed property is equal to the value we expect.

DayViewModelTests.swift

// MARK: - Tests for Date

func testDate() {
    XCTAssertEqual(viewModel.date, "Mon, June 22")
}

We can keep the unit test this simple because we control the stub. If we were to fetch a response from the weather API, the model managed by the view model would be different every time the unit test was executed. The unit test 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 DayViewModel struct. Because the value of the time computed property depends on the user's preference, stored in the user defaults database, we need to write two unit tests for complete code coverage.

DayViewModelTests.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 control the stub and, as a result, the model the view model manages.

DayViewModelTests.swift

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

    XCTAssertEqual(viewModel.time, "04:53 PM")
}

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

DayViewModelTests.swift

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

    XCTAssertEqual(viewModel.time, "16:53")
}

The remaining unit tests for the DayViewModel struct follow the same pattern. Pause the video for a moment and try to implement them. 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.

DayViewModelTests.swift

// MARK: - Tests for Summary

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

// MARK: - Tests for Temperature

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

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

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

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

// MARK: - Tests for Wind Speed

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

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

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

    print(viewModel.windSpeed)

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

// MARK: - Tests for Image

func testImage() {
    let viewModelImage = viewModel.image
    let imageDataViewModel = viewModelImage!.pngData()!
    let imageDataReference = UIImage(named: "cloudy")!.pngData()!

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

The unit test for the image computed property is slightly different. Comparing images isn't straightforward. Because image is of type UIImage?, we first assert that the value returned by image isn't equal to nil.

DayViewModelTests.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 take this as far as you like. For example, I added assertions for the dimensions of the image. This isn't critical for the application, but it shows you what is possible.

DayViewModelTests.swift

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

Before we run the test suite, we need to tie up some loose ends. In the tearDownWithError() method, we reset the state we set in the unit tests. We covered the importance of this step in the previous episode.

DayViewModelTests.swift

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

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

Running the Test Suite

What's Next?

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

Download Your Free Copy of
The Missing Manual
for Swift Development

The Guide I Wish I Had When I Started Out

Join 20,000+ Developers Learning About Swift Development

Download Your Free Copy
Next Episode "A Few More Unit Tests"