In this episode, we continue where we left off in the previous episode, that is, writing unit tests for the AddLocationViewModel class. This episode is an important one because it illustrates the impact asynchronous code can have on the unit tests you write.

The implementation of the AddLocationViewModel class isn't overly complex, but there are a few subtle caveats we need to watch out for. In this episode, you learn how to avoid those caveats and I also show you why it is important to write code that is testable.

A More Complex Unit Test

The next unit test we write covers the addLocation(with:) method of the AddLocationViewModel class. The implementation of the addLocation(with:) method isn't complex, but writing a unit test for this method is more complex than you might think. Declare an asynchronous, throwing method with name testAddLocation().

// MARK: - Tests for Add Location

func testAddLocation() async throws {
	
}

Declare a constant with name locationToAdd and assign the mock location we defined earlier in this series to it. This is the location we work with in this unit test.

// MARK: - Tests for Add Location

func testAddLocation() async throws {
    let locationToAdd = Location.mock
}

As in the previous unit test, we need to create an AddLocationViewModel instance we can use in the unit test. Create a MockStore and store a reference to it in a constant with name store.

// MARK: - Tests for Add Location

func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
}

To create an AddLocationViewModel instance, we also need a geocoding service. Create a MockGeocodingClient and assign it to a constant with name geocodingService. Note that we pass the mock location to the initializer. Revisit the episode in which we implement the MockGeocodingClient struct if you are not sure how that works.

// MARK: - Tests for Add Location

func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )
}

We use the store and the geocoding service to create an AddLocationViewModel instance. We covered that in the previous episode.

// MARK: - Tests for Add Location

func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )
}

The compiler throws an error because the AddLocationViewModel class is required to run on the main actor. We fixed that in the previous episode by applying the await keyword to the initializer and declaring the testTextFieldPlaceholder() method as async.

I want to show you a different technique that is useful for view models that run on the main actor. We can force the unit test to run on the main actor by applying the MainActor attribute to the testAddLocation() method. As I showed you in the previous episode, this isn't the only solution. Choose the solution that keeps the unit test simple and reliable. That's what's important.

// MARK: - Tests for Add Location

@MainActor
func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )
}

How do we verify that the addLocation(with:) method works as expected? We ask the store whether it contains a location whose identifier is equal to that of the mock location. For that reason, we first assert that the array of locations the store manages is empty. This may seem unnecessary, but it is a good practice to verify that the state you start with meets your expectations.

// MARK: - Tests for Add Location

@MainActor
func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )

    XCTAssertTrue(store.locations.isEmpty)
}

The locations property of the MockStore class is declared privately so we need to expose it. That is fine since the MockStore class is only used in the test suite. Open MockStore.swift and update the declaration of the locations property so that only the setter is declared privately.

import Combine
import Foundation
@testable import Thunderstorm

final class MockStore: Store {

    // MARK: - Properties

    @Published private(set) var locations: [Location]

	...

}

It is time to dive into the more complex aspects of the unit test. The addLocation(with:) method relies on the locations property of the AddLocationViewModel class. The difficulty is that the value of the locations property is the result of a successful geocoding request. What makes everything even more complex is that the view model uses the throttle operator, adding a delay that further complicates the unit test. Don't worry, though. We overcome these hurdles one by one.

We verify that the addLocation(with:) method works as expected by asking the store whether it contains a location whose identifier is equal to that of the mock location. Because the implementation is asynchronous, we cannot simply invoke the addLocation(with:) method and inspect the locations the store manages.

The good news is that Apple's XCTest framework has built-in support for testing asynchronous code. We first create an expectation. Declare a constant with name expectation that holds the XCTestExpectation instance. We create the expectation by invoking the expectation(description:) method of the XCTestCase class. We provide a description as an argument.

// MARK: - Tests for Add Location

@MainActor
func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )

    XCTAssertTrue(store.locations.isEmpty)

    let expectation = expectation(description: "Validate Locations")
}

With the expectation in place, we observe the locations publisher of the store using the sink(receiveValue:) method. To simplify the implementation, we apply the filter operator and only emit an element if the array of locations contains a location whose identifier is equal to that of the mock location. That confirms that the location was successfully added to the store. In the value handler we pass to the sink(receiveValue:) method, we fulfill the expectation. By fulfilling the expectation, we indicate that the unit test passed.

// MARK: - Tests for Add Location

@MainActor
func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )

    XCTAssertTrue(store.locations.isEmpty)

    let expectation = expectation(description: "Validate Locations")

    store.locationsPublisher
        .filter { locations in
            locations.contains { $0.id == locationToAdd.id }
        }
        .sink { _ in
            expectation.fulfill()
        }
}

The sink(receiveValue:) method returns an object of type AnyCancellable and we need to hold on to it. Add an import statement for the Combine framework at the top. Declare a private, variable property, subscriptions, of type Set<AnyCancellable> and set its initial value to an empty set.

import XCTest
import Combine
@testable import Thunderstorm

final class AddLocationViewModelTests: XCTestCase {

    // MARK: - Properties

    private var subscriptions: Set<AnyCancellable> = []

	...

}

It is important that we dispose of the subscription after the unit test finishes executing. We do that in the tearDownWithError() method. Override the tearDownWithError() method. Invoke the implementation of the parent class and call removeAll() on the subscriptions property.

// MARK: - Set Up & Tear Down

override func tearDownWithError() throws {
    try super.tearDownWithError()

    subscriptions.removeAll()
}

We can now add the AnyCancellable instance the sink(receiveValue:) method returns to the subscriptions property using the store(in:) method.

// MARK: - Tests for Add Location

@MainActor
func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )

    XCTAssertTrue(store.locations.isEmpty)

    let expectation = expectation(description: "Validate Locations")

    store.locationsPublisher
        .filter { locations in
            locations.contains { $0.id == locationToAdd.id }
        }
        .sink { _ in
            expectation.fulfill()
        }.store(in: &subscriptions)
}

We defined an expectation and that means we need to instruct the system to wait for the expectation to be fulfilled. We do that by invoking the fulfillment(of:timeout:) method of the XCTestCase class, passing in an array of expectations and a timeout. I recommend to use a timeout to make sure the unit test fails if it takes too long for the expectation to be fulfilled. Note that we prefix the invocation with the await keyword because the fulfillment(of:timeout:) method is asynchronous.

// MARK: - Tests for Add Location

@MainActor
func testAddLocation() async throws {
    let locationToAdd = Location.mock

    let store = MockStore()
    let geocodingService = MockGeocodingClient(
        result: .success([locationToAdd])
    )

    let viewModel = AddLocationViewModel(
        store: store,
        geocodingService:geocodingService
    )

    XCTAssertTrue(store.locations.isEmpty)

    let expectation = expectation(description: "Validate Locations")

    store.locationsPublisher
        .filter { locations in
            locations.contains { $0.id == locationToAdd.id }
        }
        .sink { _ in
            expectation.fulfill()
        }.store(in: &subscriptions)

    await fulfillment(of: [expectation], timeout: 10.0)
}

The unit test we are implementing is substantially more complex than the one we implemented in the previous episode. We still have some work to do, though. Let's take a break and finish the unit test in the next episode.

What's Next?

Writing unit tests for asynchronous code can be complex and that is at times inevitable. That said, it is important that you write code with testability in mind. That can greatly simplify the unit tests you need to write as well as their reliability. A common problem of asynchronous unit tests is that they can be flaky and frustrating to maintain. You can avoid that by writing code that is testable.