mokacoding

unit and acceptance testing, automation, productivity

When to test a @Published property using sink

A Test-Driven Development in Swift reader wrote with a question about testing Swift @Published properties. He noticed that some of the examples access a @Published property directly while others subscribed to it using sink and wanted to know when to use one approach instead of the other.

Here's my rule of thumb:

In a test, access a @Published property directly only when you want to verify its initial value. Otherwise, subscribe to it.

Let's dig deeper to understand how @Published properties behave in unit tests.

How @Published properties work

The Swift docs explain that annotating a property with @Published creates a Publisher for it. The publisher will emit a new value every time the property changes, including on its first assignment.

We can read a @Published property value like any other property.

@Published var items: [Item] = []

print(items.count) // => 0

We can access the generated Publisher via the $ prefix and subscribe to it to observe how the property changes over time:

$items.sink { print($0) }

How you interact with a @Published property –in the tests as well as the production code– depends on which of its possible values you want. Are you interested in its value right now or in the value it will have in the future?

How to test @Published properties with direct access

Here's an example from Test-Driven Development in Swift Chapter 7, Testing Dynamic SwiftUI Views. We want to ensure the initial value of the menu list ViewModel menu section property is an empty array. The property is defined as:

@Published var sections: [MenuSection] = []

In the test, we access sections directly:

func testPublishesEmptyMenuAtInit() {
    let viewModel = MenuList.ViewModel(menuFetching: MenuFetchingPlaceholder())

    XCTAssertTrue(viewModel.sections.isEmpty)
}

You can find the code above and all the other examples from the book on GitHub.

@Published properties stream values over time, but when you access them directly, they return their current value. Above, we are interested in the property's initial value, so we can read from it directly.

How to test @Published properties by subscribing to them

Nothing stops us from subscribing to it, but as you can see from the example below, it requires extra effort:

func testPublishesEmptyMenuAtInit() {
    let viewModel = MenuList.ViewModel(menuFetching: MenuFetchingPlaceholder())

    let expectation = XCTestExpectation(description: "The first publishes value is an empty section")

    viewModel
        .$sections
        .sink { value in
            XCTAssertTrue(value.isEmpty)
            expectation.fulfill()
        }
        .store(in: &cancellables)

    wait(for: [expectation], timeout: 1)
}

Because subscribing to a Publisher requires more code, I do that only when absolutely necessary. Alas, that turns out to be most of the time.

The whole point of making a property @Published is to support streaming value changes. We're seldom interested in its initial value alone, which means most tests @Published properties require subscribing to it.

Here's another example inspired by the book, where we check how sections changes due to a successful response from the fetching component.

func testWhenFetchingSucceedsPublishesReceivedSections() {
    let viewModel = MenuList.ViewModel(menuFetching: MenuFetchingPlaceholder())

    let expectation = XCTestExpectation(description: "Publishes sections built from received menu")

    viewModel
        .$sections
        // Drop first to ignore the initial value
        .dropFirst()
        .sink { value in
            // For the sake of this example, check the count only
            XCTAssertEqual(value.count, 4)
            expectation.fulfill()
        }
        .store(in: &cancellables)

    wait(for: [expectation], timeout: 1)
}

Because we are interested in how section changes, we need to subscribe to it.

Combining subscription and direct access

If one wanted to be pedantic, they could point out that we can write the same test with direct access by reading the property after the expectation is fulfilled.

func testWhenFetchingSucceedsPublishesReceivedSections() {
    let viewModel = MenuList.ViewModel(menuFetching: MenuFetchingPlaceholder())

    let expectation = XCTestExpectation(description: "Publishes sections built from received menu")

    viewModel
        .$sections
        .dropFirst()
        .sink { _ in
            expectation.fulfill()
        }
        .store(in: &cancellables)

    wait(for: [expectation], timeout: 1)

    XCTAssertEqual(viewModel.sections.count, 4)
}

A test written like that will work most of the time. But, as my friend Rob Amos pointed out in the Melbourne CocoaHeads Slack, @Published notifies subscribers from the property's willSet block. That means that there is a chance, however slim, that the assertion will run before the property value actually changes. Therefore, it's safer to run the assertion from within the sink observer closure.

Recap

To recap, whenever you want to assert the value of a @Published property after it changes, you'll have to subscribe to it with sink. It's up to you to verify the value it will have taken directly in the sink closure or afterward by accessing the property directly — I recommend the former.

If you want to learn more about testing Combine Publisher and @Published properties, check out my Unit Testing Combine Publisher Cheatsheet and, of course, _Test-Driven Development in Swift.

Want more of these posts?

Subscribe to receive new posts in your inbox.