SwiftUI Combine - How to test waiting for a publisher's async result
I am listening for changes of a publisher, then fetching some data asynchronously in my pipeline and updating the view with the result. However, I am unsure how to make this testable. How can I best wait until the expectation has been met?
View
struct ContentView: View {
@StateObject var viewModel = ContentViewModel()
var body: some View {
NavigationView {
List(viewModel.results, id: \.self) {
Text($0)
}
.searchable(text: $viewModel.searchText)
}
}
}
ViewModel
final class ContentViewModel: ObservableObject {
@Published var searchText: String = ""
@Published var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
observeSearchText()
}
func observeSearchText() {
$searchText
.dropFirst()
.debounce(for: 0.8, scheduler: DispatchQueue.main)
.sink { _ in
Task {
await self.fetchResults()
}
}.store(in: &cancellables)
}
private func fetchResults() async {
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
self.results = ["01", "02", "03"]
} catch {
//
}
}
}
Tests
class ContentViewTests: XCTestCase {
func testExample() {
// Given
let viewModel = ContentViewModel()
// When
viewModel.searchText = "123"
// Then (FAILS - Not waiting properly for result/update)
XCTAssertEqual(viewModel.results, ["01", "02", "03"])
}
}
Current Workaround
If I make fetchResults()
available I can async/await which works for my unit and snapshot tests, but I was worried that:
- It is bad practice to expose if it isn't to be called externally?
- I'm not testing my publisher pipeline
func testExample_Workaround() async {
// Given
let viewModel = ContentViewModel()
// When
await viewModel.fetchResults()
// Then
XCTAssertEqual(viewModel.results, ["01", "02", "03"])
}
Solution 1:
You need to wait asynchronously via expectation and check result via publisher.
Here is possible approach. Tested with Xcode 13.2 / iOS 15.2
private var cancelables = Set<AnyCancellable>()
func testContentViewModel() {
// Given
let viewModel = ContentViewModel()
let expect = expectation(description: "results")
viewModel.$results
.dropFirst() // << skip initial value !!
.sink {
XCTAssertEqual($0, ["01", "02", "03"])
expect.fulfill()
}
.store(in: &cancelables)
viewModel.searchText = "123"
wait(for: [expect], timeout: 3)
}