Search code examples
swiftuicombineswift-concurrency

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

  • 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