🎉 Second Edition Released 🎉

Chapter 7 – Exercise Solution

In Chapter 7, we learned how SwiftUI and Combine work together to keep the view up to date seamlessly.
We saw how to configure an ObservableObject to expose a @Published property and make the view update every time the property changes using @ObservedObject.

The synergy between SwiftUI and Combine takes care of all the heavy lifting involved in syncing dynamic views and streamlines how we approach developing the view layer driven by tests.
In SwiftUI, views are a function of state, not a sequence of events, and we can be confident that every time we feed the same state to a SwiftUI view, it will render it in the same way.
This simplifies testing because we only need to test the logic that produces the state we input in the view.
We don’t need to worry about all the view layout configuration and state management; the framework does that for us.

In the book, we tested how MenuItem.ViewModel publishes its section with the implicit assumption that there will only be one published value.
That’s the behavior we expect from a network request to an API, an HTTP call that can either succeed or fail.

A Combine Publisher, on the other hand, can emit many values over time.

How to test a Combine Publisher that emits multiple values

To test a Publisher behavior over multiple events, we need a way to store each value it emits.
Let’s see how to do this by rewriting the test for how MenuItem.ViewModel publishes menu sections when the fetch request succeeds.

A @Published property requires a default value and will emit that as soon as a subscriber attaches to it.
In the book, we used dropFirst to discard that value and check the first and only value our codes emits through the @Published property.

Let’s remove dropFirst() and see how our test behaves.

@Test func publishedSections() async {
    var receivedMenu: [MenuItem]?
    let expectedSections = [MenuSection.fixture()]

    let spyClosure: ([MenuItem]) -> [MenuSection] = { items in
        receivedMenu = items
        return expectedSections
    }

    let viewModel = MenuList.ViewModel(
        menuFetching: MenuFetchingPlaceholder(),
        menuGrouping: spyClosure
    )

    var cancellables: Set<AnyCancellable> = []

    await confirmation("Publishes sections built from received menu and given grouping closure") { confirm in
        viewModel
            .$sections
            // the .dropFirst() call was here
            .sink { value in
                // Ensure the grouping closure is called with the received menu
                #expect(receivedMenu == menu)
                  // ❌ Expectation failed: receivedMenu == menu (nil vs ...)

                // Ensure the published value is the result of the grouping closure
                #expect(value == expectedSections)
                  // ❌ Expectation failed: value == expectedSections ([] vs ...)

                confirm()
            }
            .store(in: &cancellables)

        try? await Task.sleep(for: .seconds(1))
    }
}

The test fails.
That’s not surprising because by removing .dropFirst, the first value received in the sink closure is the default value defined in the ViewModel:

// MenuItem.ViewModel.swift
import Combine

extension MenuList {

    class ViewModel: ObservableObject {

        @Published private(set) var sections: [MenuSection] = []

How can we store all the values emitted by the @Published sections underlying Publisher?
And how can we verify they match our expectations?

We can collect all the values emitted in an array and inspect them once a certain condition is met.
By looking at the stored values count, we can determine whether to inspect the array or wait for more values.
In our case, we expect to receive an empty value first and a full value second.

@Test func publishedSections() async {
    var receivedMenu: [MenuItem]?
    let expectedSections = [MenuSection.fixture()]

    let spyClosure: ([MenuItem]) -> [MenuSection] = { items in
        receivedMenu = items
        return expectedSections
    }

    let viewModel = MenuList.ViewModel(
        menuFetching: MenuFetchingPlaceholder(),
        menuGrouping: spyClosure
    )

    // This is where we'll collect all the values published by `$sections`
    var values: [[MenuSection]] = []
    var cancellables: Set<AnyCancellable> = []

    await confirmation("Publishes default then sections from received menu and grouping closure") { confirm in
        viewModel
            .$sections
            .sink { value in
                // 1. Store the new value the Publisher emitted
                values = values + [value]

                // 2. Inspect the array of received values to decide whether to
                // continue collecting value.
                //
                // We expect to receive two values: the first is the empty array default, the
                // second the result of the menu fetch.
                guard values.count == 2 else { return }

                // 3. At this point, the condition on the received values has been
                // met and we can assert the values match our expectations.

                // 4.a. We expect the first value to be an empty array.
                //
                // We cannot use `#require` here because it throws but `sink` is not declared
                // with `rethrows`.
                //
                // Notice that we could also call force unwrap with `values.first!` because the
                // `guard` above guarantees we'll have exactly two items at runtime.
                //
                // I prefer this more verbose approach because it prevents the tests from
                // crashing in case the `guard` is accidentally removed.
                guard let defaultSections = values.first else {
                    Issue.record("Values has no elements, but expected one")
                    return
                }
                #expect(defaultSections.isEmpty)

                // 4.b. We expect the second value to have been constructed using
                // the received sections and the given grouping closure.
                guard let sections = values[safe: 1] else {
                    Issue.record("Expected a value at index 1, got none")
                    return
                }

                // Ensure the grouping closure is called with the received menu
                #expect(receivedMenu == menu)
                // Ensure the published value is the result of the grouping closure
                #expect(sections == expectedSections)
                confirm()
            }
            .store(in: &cancellables)

        try? await Task.sleep(for: .seconds(1))
    }
}

This new version of the test passes.

To recap: To test a Publisher behavior over multiple emitted values, you need to collect the values by subscribing to it with sink.
Once you’ve collected as many values as expected for the behavior under test, you can inspect each value in order and run the appropriate assertions on it.

For more examples of how to test different Combine Publisher behaviors, checkout my Unit Testing Combine Publisher Cheatsheet.

← Back to home