admin管理员组

文章数量:1026170

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?

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?

Share Improve this question edited Nov 18, 2024 at 7:56 lazarevzubov asked Nov 17, 2024 at 13:30 lazarevzubovlazarevzubov 2,3423 gold badges18 silver badges32 bronze badges 3
  • The example you gave is quite a bad/contrived example. Assuming image is @Published, the test (unlike what you claimed) passes. This is because @Published properties always publish their current value synchronously when you first subscribe to them. Presumably you want to confirm some value that will be published asynchronously. How about using Just<Int?>(1).delay(for: 1, scheduler: DispatchQueue.main) as an example instead? – Sweeper Commented Nov 17, 2024 at 16:43
  • @Sweeper You're right, I wanted to simplify code for the example and accidentally made it unrealistic. The real code has also dropFirst(), which ignores the initial value—added to the example. Now the code is exactly as in the real project I'm working on. – lazarevzubov Commented Nov 18, 2024 at 7:58
  • Okay. Both Rob’s answer and my answer should still stand. – Sweeper Commented Nov 18, 2024 at 8:06
Add a comment  | 

2 Answers 2

Reset to default 6

You are correct, that “confirmation doesn’t ‘wait’ until the publisher emits a value.” As that document says:

Confirmations function similarly to the expectations API of XCTest, however, they don’t block or suspend the caller while waiting for a condition to be fulfilled. Instead, the requirement is expected to be confirmed (the equivalent of fulfilling an expectation) before confirmation() returns, and records an issue otherwise.

If you look at the confirmation examples in that doc, they all await some async call before returning.

But you can use values to render your Publisher as an AsyncSequence, which you can simply await in an async test. So rather than sink, just get the first value from this asynchronous sequence:

@Test
func publisherValue() async {
    let publisher = …
    let value = await publisher.values.first()
    #expect(value != nil)
}

So, in your case, you should be able to do:

@Test
func imageRetrieved() async {
    let value = await viewModel.$image.values.first()
    #expect(value != nil)
}

With this extension to simplify the call to first(where:):

extension AsyncSequence {
    func first() async rethrows -> Element? {
        try await first(where: { _ in true})
    }
}

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.

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?

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?

Share Improve this question edited Nov 18, 2024 at 7:56 lazarevzubov asked Nov 17, 2024 at 13:30 lazarevzubovlazarevzubov 2,3423 gold badges18 silver badges32 bronze badges 3
  • The example you gave is quite a bad/contrived example. Assuming image is @Published, the test (unlike what you claimed) passes. This is because @Published properties always publish their current value synchronously when you first subscribe to them. Presumably you want to confirm some value that will be published asynchronously. How about using Just<Int?>(1).delay(for: 1, scheduler: DispatchQueue.main) as an example instead? – Sweeper Commented Nov 17, 2024 at 16:43
  • @Sweeper You're right, I wanted to simplify code for the example and accidentally made it unrealistic. The real code has also dropFirst(), which ignores the initial value—added to the example. Now the code is exactly as in the real project I'm working on. – lazarevzubov Commented Nov 18, 2024 at 7:58
  • Okay. Both Rob’s answer and my answer should still stand. – Sweeper Commented Nov 18, 2024 at 8:06
Add a comment  | 

2 Answers 2

Reset to default 6

You are correct, that “confirmation doesn’t ‘wait’ until the publisher emits a value.” As that document says:

Confirmations function similarly to the expectations API of XCTest, however, they don’t block or suspend the caller while waiting for a condition to be fulfilled. Instead, the requirement is expected to be confirmed (the equivalent of fulfilling an expectation) before confirmation() returns, and records an issue otherwise.

If you look at the confirmation examples in that doc, they all await some async call before returning.

But you can use values to render your Publisher as an AsyncSequence, which you can simply await in an async test. So rather than sink, just get the first value from this asynchronous sequence:

@Test
func publisherValue() async {
    let publisher = …
    let value = await publisher.values.first()
    #expect(value != nil)
}

So, in your case, you should be able to do:

@Test
func imageRetrieved() async {
    let value = await viewModel.$image.values.first()
    #expect(value != nil)
}

With this extension to simplify the call to first(where:):

extension AsyncSequence {
    func first() async rethrows -> Element? {
        try await first(where: { _ in true})
    }
}

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.

本文标签: How to test Combine Publishers in Swift TestingStack Overflow