In the previous episode, we wrote unit tests for a simple view model, the AddLocationCellViewModel struct. In this episode, we take it up a notch and write unit tests for a complex view model. We take the AddLocationViewModel class as an example.

A Complex View Model

The AddLocationViewModel class is a complex view model for a few reasons. It doesn't simply expose data the view displays. It responds to user interaction, manipulates user input, and asynchronously returns data the view displays. It does this with the help of a few dependencies, a Store and a GeocodingService. Most of the implementation of the AddLocationViewModel class is private. How does that impact the unit tests we write for the AddLocationViewModel class?

Mocking Dependencies

Before we write unit tests for the AddLocationViewModel class, we need to mock the dependencies the view model relies on. This is essential if our goal is to write unit tests that are fast, robust, and reliable.

The good news is that mocking the dependencies of the view model isn't difficult. Thanks to the protocol-oriented approach we applied earlier in this series, mocking the dependencies is painless. Let's start with the view model's first dependency, the Store.

Creating a Mock Store

The idea is simple. We implement a type that conforms to the Store protocol. We use that type in the unit tests we write for the AddLocationViewModel class. Add a group with name Mocks to the ThunderstormTests group. Add a Swift file to the Mocks group and name it MockStore.swift. Add an import statement for the Combine framework and an import statement for the Thunderstorm target. Don't forget to prefix the import statement for the Thunderstorm target with the testable attribute.

import Combine
import Foundation
@testable import Thunderstorm

Declare a final class with name MockStore that conforms to the Store protocol.

import Combine
import Foundation
@testable import Thunderstorm

final class MockStore: Store {
	
}

Declare a Published, private, variable property, locations, of type [Location].

import Combine
import Foundation
@testable import Thunderstorm

final class MockStore: Store {

    // MARK: - Properties

    @Published private var locations: [Location]

}

We also declare an initializer that accepts an array of Location objects as an argument.

import Combine
import Foundation
@testable import Thunderstorm

final class MockStore: Store {

    // MARK: - Properties

    @Published private var locations: [Location]

    // MARK: - Initialization

    init(locations: [Location] = []) {
        self.locations = locations
    }

}

The next step is implementing the property and methods of the Store protocol. We start with the computed locationsPublisher property. In the body of the computed property, we access the locations publisher and wrap it with a type eraser using the eraseToAnyPublisher() method.

// MARK: - Store

var locationsPublisher: AnyPublisher<[Location], Never> {
    $locations
        .eraseToAnyPublisher()
}

Implementing the addLocation(_:) method is just as easy. In the body of the addLocation(_:) method, we append the Location object the method accepts as an argument to the array of Location objects.

// MARK: - Store

var locationsPublisher: AnyPublisher<[Location], Never> {
    $locations
        .eraseToAnyPublisher()
}

func addLocation(_ location: Location) throws {
    locations.append(location)
}

Implementing the removeLocation(_:) method isn't difficult either. In the body of the removeLocation(_:) method, we invoke the removeAll(where:) method on the locations property. We use the id property of the Location struct to remove the location from the array of locations.

// MARK: - Store

var locationsPublisher: AnyPublisher<[Location], Never> {
    $locations
        .eraseToAnyPublisher()
}

func addLocation(_ location: Location) throws {
    locations.append(location)
}

func removeLocation(_ location: Location) throws {
    locations.removeAll { $0.id == location.id }
}

I'm sure you agree that creating a mock store wasn't too difficult. Let's repeat these steps and create a mock geocoding service.

Creating a Mock Geocoding Service

Add a Swift file to the Mocks group and name it MockGeocodingClient.swift. Add an import statement for the Thunderstorm target and prefix the import statement with the testable attribute. Declare a struct with name MockGeocodingClient that conforms to the GeocodingService protocol.

import Foundation
@testable import Thunderstorm

struct MockGeocodingClient: GeocodingService {
	
}

The GeocodingService protocol declares a single method, geocodeAddressString(_:). It returns an array of Location objects on success and throws an error when something goes wrong. We want to make the mock geocoding service flexible so it should support both scenarios, success and failure.

Declare a property with name result of type Result. The type for the success case is [Location]. The type for the failure case is GeocodingError.

import Foundation
@testable import Thunderstorm

struct MockGeocodingClient: GeocodingService {

    // MARK: - Properties

    let result: Result<[Location], GeocodingError>
    
}

Before we continue, we need to implement GeocodingError. Declare a nested enum with name GeocodingError that conforms to the Error protocol. The GeocodingError enum declares a single case, requestFailed.

import Foundation
@testable import Thunderstorm

struct MockGeocodingClient: GeocodingService {

    // MARK: - Types

    enum GeocodingError: Error {

        // MARK: - Cases

        case requestFailed

    }

    // MARK: - Properties

    let result: Result<[Location], GeocodingError>

}

We can now implement the geocodeAddressString(_:) method of the GeocodingService protocol. The implementation is quite simple. In the body of the geocodeAddressString(_:) method, we switch on the value of the result property. For the success case, the method returns the array of locations, the associated value of the success case. For the failure case, the method throws an error, the associated value of the failure case. That's it.

import Foundation
@testable import Thunderstorm

struct MockGeocodingClient: GeocodingService {

    // MARK: - Types

    enum GeocodingError: Error {

        // MARK: - Cases

        case requestFailed

    }

    // MARK: - Properties

    let result: Result<[Location], GeocodingError>

    // MARK: - Geocoding Service

    func geocodeAddressString(_ addressString: String) async throws -> [Location] {
        switch result {
        case .success(let locations):
            return locations
        case .failure(let error):
            throw error
        }
    }

}

Writing the First Unit Test

Let's put the mock dependencies we created to use by writing the first unit test for the AddLocationViewModel class. Add an XCTestCase subclass to the Cases group of the ThunderstormTests group. Name the subclass AddLocationViewModelTests. Add an import statement for the Thunderstorm target. As before, we prefix the import statement with the testable attribute. Remove the implementation of the XCTestCase subclass. We start with a clean slate.

import XCTest
@testable import Thunderstorm

final class AddLocationViewModelTests: XCTestCase {
	
}

Declare a throwing method with name testTextFieldPlaceholder(). As the name suggests, the method unit tests the computed textFieldPlaceholder property of the AddLocationViewModel class.

import XCTest
@testable import Thunderstorm

final class AddLocationViewModelTests: XCTestCase {

    // MARK: - Tests for Text Field Placeholder

    func testTextFieldPlaceholder() throws {

	}

}

Declare a constant with name viewModel. We create an instance of the AddLocationViewModel class and store a reference to it in the viewModel constant. Remember that the initializer takes two arguments, a Store and a GeocodingService. We create a MockStore instance and pass it to the initializer as the first argument. We create a MockGeocodingClient object and pass it as the second argument of the initializer.

import XCTest
@testable import Thunderstorm

final class AddLocationViewModelTests: XCTestCase {

    // MARK: - Tests for Text Field Placeholder

    func testTextFieldPlaceholder() throws {
        let viewModel = AddLocationViewModel(
            store: MockStore(),
            geocodingService: MockGeocodingClient(
                result: .success(Location.mocks)
            )
        )
    }

}

The compiler throws an error because we overlooked that the AddLocationViewModel class is required to run on the main actor. Remember that we annotated the class declaration of the AddLocationViewModel class with the MainActor attribute. The fix is straightforward, though. We apply the await keyword to the initializer and declare the testTextFieldPlaceholder() method as async. That's it.

import XCTest
@testable import Thunderstorm

final class AddLocationViewModelTests: XCTestCase {

    // MARK: - Tests for Text Field Placeholder

    func testTextFieldPlaceholder() async throws {
        let viewModel = await AddLocationViewModel(
            store: MockStore(),
            geocodingService: MockGeocodingClient(
                result: .success(Location.mocks)
            )
        )
    }

}

The remainder of the unit test is simple. We store the return value of the computed textFieldPlaceholder property of the view model in a constant with name textFieldPlaceholder. Note that we use await to make sure the computed property is accessed from the main thread. Last but not least, we assert that the value of the textFieldPlaceholder constant is equal to the value we expect.

import XCTest
@testable import Thunderstorm

final class AddLocationViewModelTests: XCTestCase {

    // MARK: - Tests for Text Field Placeholder

    func testTextFieldPlaceholder() async throws {
        let viewModel = await AddLocationViewModel(
            store: MockStore(),
            geocodingService: MockGeocodingClient(
                result: .success(Location.mocks)
            )
        )

        let textFieldPlaceholder = await viewModel.textFieldPlaceholder
        XCTAssertEqual(textFieldPlaceholder, "Enter the name of a city ...")
    }

}

Run the test suite to make sure the unit test we wrote for the AddLocationViewModel class passes without issues.

What's Next?

With MockStore and MockGeocodingClient in place, we can write the remaining unit tests for the AddLocationViewModel class. Thanks to these types, we are in control of the environment the unit tests run in. The unit tests don't depend on the Core Location framework, for example, and that makes them fast, robust, and reliable.