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.