iosswiftasynchronouscombine

How to unit test asynchronous Combine based code without XCTestExpectation or fixed waiting times


In iOS development using Swift, what options do I have for testing code that utilizes the Combine framework? Specifically, I'm looking to test a function that returns an AnyPublisher asynchronously. I prefer not to use expectations or any approaches that require specifying a waiting time. Are there methodologies similar to async/await code testing that I can use?

Here's an example of the function I need to test:

import Combine

func fetchData() -> AnyPublisher<Data, Error> {
    // Assume this is some asynchronous network call returning AnyPublisher
    someAsyncCall.
        .map(\.data)
        .eraseToAnyPublisher()
}

I am interested in understanding how to test the fetchData function effectively using best practices for Combine


Solution

  • To test Combine code in Swift without relying on XCTestExpectation or fixed waiting times, you can use Combine’s .sink and Swift’s async/await support with the new XCTest measure functions, which allows you to seamlessly integrate Combine into an asynchronous testing flow.

    Here's how to test fetchData asynchronously:

    import XCTest
    import Combine
    
    class FetchDataTests: XCTestCase {
        var cancellables = Set<AnyCancellable>()
        
        func testFetchData() async throws {
            // Given
            let expectedData = Data() // Assume we expect some data here
            let fetcher = YourFetcher() // Your class or struct that holds fetchData
    
            // When
            let data = try await fetcher.fetchData().firstValue()
            
            // Then
            XCTAssertEqual(data, expectedData, "Fetched data should match expected data")
        }
    }
    
    extension Publisher {
        /// Converts a Publisher to an async sequence and returns the first value emitted.
        func firstValue() async throws -> Output {
            try await withCheckedThrowingContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = first()
                    .sink(receiveCompletion: { completion in
                        if case .failure(let error) = completion {
                            continuation.resume(throwing: error)
                        }
                        cancellable?.cancel()
                    }, receiveValue: { value in
                        continuation.resume(returning: value)
                        cancellable?.cancel()
                    })
            }
        }
    }