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:

  1. It is bad practice to expose if it isn't to be called externally?
  2. 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)
    }

demo