Search code examples
swiftcombineswift-testing

How to test Combine Publishers in Swift Testing?


Consider this XCTest-based unit test:

func testImageRetrieved() {
  let expectation = XCTestExpectation()
  let cancellable = viewModel.$image.dropFirst().sink { _ in
    // Do nothing on completion.
  } receiveValue: {
    XCTAssertNotNil($0)
    expectation.fulfill()
  }
  wait(for: [expectation], timeout: 1.0)

  cancellable.cancel()
}

According to Apple's Migrating a test from XCTest, this should be directly translatable into this Swift Testing-based method:

@Test
func imageRetrieved() async {
  var cancellable: AnyCancellable?
  await confirmation { imageRetrieved in
    cancellable = viewModel.$image.dropFirst().sink { _ in
      // Do nothing on completion.
    } receiveValue: {
      #expect($0 != nil)
      imageRetrieved()
    }
  }
  cancellable?.cancel()
}

The latter test, however, fails with the error saying: "Confirmation was confirmed 0 times, but expected to be confirmed 1 time." It looks like the confirmation doesn't "wait" until the publisher emits a value.

What is the proper way to test Combine publishers in Swift Testing?


Solution

  • It looks like the confirmation doesn't "wait" until the publisher emits a value.

    Correct. the documentation says,

    When the closure returns, the testing library checks if the confirmation’s preconditions have been met, and records an issue if they have not.

    The closure in this case returns almost immediately, since all it does is assign to a variable. The test will not see any values that are published asynchronously.

    Compare this to the example in the migration guide.

    struct FoodTruckTests {
      @Test func truckEvents() async {
        await confirmation("…") { soldFood in
          FoodTruck.shared.eventHandler = { event in
            if case .soldFood = event {
              soldFood()
            }
          }
          await Customer().buy(.soup)
        }
        ...
      }
      ...
    }
    

    At the end of the closure there, await Customer().buy(.soup) is called, and this is presumably what will trigger FoodTruck.shared.eventHandler.


    For publishers, you can easily get its values as an AsyncSequence, which is much easier to consume. For example, to check that the publisher publishes at least one element, and that the first element is not nil. you can do:

    @Test func someTest() async throws {
        var iterator = publisher.values.makeAsyncIterator()
        let first = await iterator.next()
        #expect(first != nil)
    }
    
    // example publisher:
    var publisher: some Publisher<Int?, Never> {
        Just<Int?>(1).delay(for: 1, scheduler: DispatchQueue.main)
    }
    

    Also consider adding a timeout before .values.

    Alternatively, you can manually wait by just calling Task.sleep. The following tests that publisher publishes exactly one element, which is not nil, in a 2 second period.

    @Test func someTest() async throws {
        var cancellable: Set<AnyCancellable> = []
        try await confirmation { confirmation in
            publisher.sink { value in
                #expect(first != nil)
                confirmation()
            }.store(in: &cancellable)
            try await Task.sleep(for: .seconds(2))
        }
    }
    

    Of course, this makes the test run for at least 2 seconds, which might be undesirable sometimes.