CombineExpectations

repository·master·Indexed 42 days ago

https://github.com/groue/combineexpectations

A utility library for simplifying the testing of Combine publishers in Swift. It provides an XCTest-integrated workflow to asynchronously wait for publisher behaviors—such as emitting elements or completing—using a recorder and expectations like next(), single, and availableElements, reducing the boilerplate required when using XCTestExpectation.

Tokens
4.1K
Snippets
14
Records
14
Agent score
4%

What's inside CombineExpectations

  1. How CombineExpectations works

    master

    CombineExpectations streamlines testing Combine publishers by reducing the boilerplate required when using XCTestExpectation.

    Core Workflow

    1. Record: Call .record() on a publisher to create a recorder object.
    2. Wait or Get: Use wait(for:timeout:description:) to asynchronously wait for a specific state (like elements being emitted or the publisher finishing), or use .get() for synchronous retrieval if the expectation is already met.
    3. Assert: Use standard XCTAssert methods on the returned values.

    Behavior Rules

    • Timeouts: The test fails if an expectation is not fulfilled within the specified timeout.
    • Errors: If a publisher completes with a failure, the wait method throws that publisher error.
    • Immediate Return: The wait method returns immediately if the expectation has already reached the desired state.
    • Multiple Waits: You can call wait multiple times on the same recorder to step through a publisher's lifecycle (e.g., waiting for the first element, then the second, then completion).
    import XCTest
    import CombineExpectations
    
    class PublisherTests: XCTestCase {
        func testElements() throws {
            let publisher = ...
            let recorder = publisher.record()
            
            // Wait for a publisher expectation
            let elements = try wait(for: recorder.elements, timeout: ..., description: "Elements")
            
            XCTAssertEqual(elements, ["Hello", "World!"])
        }
    }
  2. Install CombineExpectations via Swift Package Manager

    master

    Add CombineExpectations as a dependency to your Swift Package Manager (SPM) test targets. Ensure you add the package URL to your Package.swift dependencies and include the library in your specific .testTarget dependencies.

     let package = Package(
         dependencies: [
    +        .package(url: "https://github.com/groue/CombineExpectations.git", ...)
         ],
         targets: [
             .testTarget(
                 dependencies: [
    +                "CombineExpectations"
                 ])
             ]
         )
     )
  3. Use `single` to verify exactly one element

    master

    The recorder.single expectation waits for the recorded publisher to complete and asserts that exactly one element was published.

    Key Behaviors:

    • Return Value: Returns the single published element.
    • Error Handling:
      • Throws RecordingError.tooManyElements if more than one element is published.
      • Throws RecordingError.notEnoughElements if no elements are published.
      • Throws RecordingError.notCompleted if the publisher does not complete on time.
      • Throws the publisher's error if it fails.

    Use this for publishers like Just where you expect exactly one value followed by completion.

    // SUCCESS: no timeout, no error
    func testJustPublishesExactlyOneElement() throws {
        let publisher = Just("foo")
        let recorder = publisher.record()
        let element = try wait(for: recorder.single, timeout: ...)
        XCTAssertEqual(element, "foo")
    }
  4. Use `recording` to get the full event history

    master

    The recorder.recording expectation waits for the recorded publisher to complete and returns the full Record.Recording object.

    Key Behaviors:

    • Return Value: Returns a Record.Recording containing the output (array of elements) and the completion status.
    • Error Handling: Throws RecordingError.notCompleted if the publisher does not complete within the timeout.

    Use this when you need to inspect both the emitted values and the final completion status (success or failure) in a single assertion.

    // SUCCESS: no timeout, no error
    func testArrayPublisherRecording() throws {
        let publisher = ["foo", "bar", "baz"].publisher
        let recorder = publisher.record()
        let recording = try wait(for: recorder.recording, timeout: ...)
        XCTAssertEqual(recording.output, ["foo", "bar", "baz"])
        if case let .failure(error) = recording.completion {
            XCTFail("Unexpected error \(error)")
        }
    }
  5. Use `availableElements` to check elements published before timeout

    master

    The recorder.availableElements expectation waits for the expectation to expire or for the recorded publisher to complete.

    Key Behaviors:

    • Timeout Behavior: Unlike most other expectations, availableElements does not make a test fail on timeout expiration. It simply returns the array of elements published up to that point.
    • Error Handling: If the publisher fails (emits an error) before the expectation expires, the publisher's error is thrown.

    Use this when you want to assert on whatever data has arrived within a specific timeframe without requiring the publisher to finish.

    // SUCCESS: no timeout, no error
    func testTimerPublishesIncreasingDates() throws {
        let publisher = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
        let recorder = publisher.record()
        let dates = try wait(for: recorder.availableElements, timeout: ...)
        XCTAssertEqual(dates.sorted(), dates)
    }
  6. Retrieve publisher elements synchronously using get()

    master

    If a publisher expectation is fulfilled immediately (synchronously), use the .get() method on a recorder's expectation instead of wait(for:timeout:). This avoids unnecessary waiting and makes tests cleaner.

    • Returns: The expected value immediately.
    • Throws: Throws if the expectation is not yet met or if the publisher failed.
    let publisher = PassthroughSubject<String, Never>()
    let recorder = publisher.record()
    
    publisher.send("foo")
    try XCTAssertEqual(recorder.next().get(), "foo")
  7. Use `elements` to wait for all published elements

    master

    The recorder.elements expectation waits for the recorded publisher to complete and returns all published elements.

    Key Behaviors:

    • Return Value: Returns an array of all published elements.
    • Error Handling:
      • Throws RecordingError.notCompleted if the publisher does not complete on time.
      • Throws the publisher's own error if the publisher fails before completion.

    Use this when you need to assert against the entire sequence of values emitted by a publisher that is expected to finish.

    // SUCCESS: no timeout, no error
    func testArrayPublisherPublishesArrayElements() throws {
        let publisher = ["foo", "bar", "baz"].publisher
        let recorder = publisher.record()
        let elements = try wait(for: recorder.elements, timeout: ...)
        XCTAssertEqual(elements, ["foo", "bar", "baz"])
    }
  8. Use `next(count)` to wait for N elements

    master

    The recorder.next(count) expectation waits for the recorded publisher to emit a specific number of elements (count), or to complete.

    Key Behaviors:

    • Return Value: Returns an array containing exactly count elements.
    • Error Handling:
      • Throws RecordingError.notEnoughElements if the publisher completes before emitting the requested count.
      • Throws the publisher's error if it fails before the count is met.

    Use this to verify that a publisher emits a specific batch of values.

    // SUCCESS: no timeout, no error
    func testArrayOfThreeElementsPublishesTwoThenOneElement() throws {
        let publisher = ["foo", "bar", "baz"].publisher
        let recorder = publisher.record()
        
        var elements = try wait(for: recorder.next(2), timeout: ...)
        XCTAssertEqual(elements, ["foo", "bar"])
        
        elements = try wait(for: recorder.next(1), timeout: ...)
        XCTAssertEqual(elements, ["baz"])
    }
  9. Use `prefix(maxLength)` to wait for up to N elements

    master

    The recorder.prefix(maxLength) expectation waits for the recorded publisher to emit up to maxLength elements, or to complete.

    Key Behaviors:

    • Return Value: Returns an array of received elements. This array contains at most maxLength elements, but may contain fewer if the publisher completes early.
    • Error Handling: Throws the publisher's error if it fails before maxLength elements are published.
    • Inversion: Can be used with .inverted to assert that no more than a certain number of elements are published.

    Use this when you want to capture a subset of a stream without requiring the stream to reach a specific count or complete.

    // SUCCESS: no timeout, no error
    func testArrayOfThreeElementsPublishesTwoFirstElementsWithoutError() throws {
        let publisher = ["foo", "bar", "baz"].publisher
        let recorder = publisher.record()
        let elements = try wait(for: recorder.prefix(2), timeout: ...)
        XCTAssertEqual(elements, ["foo", "bar"])
    }
  10. Use `completion` to wait for publisher completion

    master

    The recorder.completion expectation waits for the recorded publisher to complete.

    Key Behaviors:

    • Return Value: Returns a Subscribers.Completion object.
    • Error Handling: Throws RecordingError.notCompleted if the publisher does not complete within the specified timeout. If the publisher completes with a failure, that error is returned within the Subscribers.Completion object.

    Use this to verify if a publisher finished successfully or failed with a specific error.

    // SUCCESS: no timeout, no error
    func testArrayPublisherCompletesWithSuccess() throws {
        let publisher = ["foo", "bar", "baz"].publisher
        let recorder = publisher.record()
        let completion = try wait(for: recorder.completion, timeout: ...)
        if case let .failure(error) = completion {
            XCTFail("Unexpected error \(error)")
        }
    }
  11. Wait for publisher elements asynchronously

    master

    Use wait(for:timeout:description:) to pause the test until a publisher emits a specific set of elements or completes. This is useful for asynchronous publishers where you need to wait for data to arrive.

    • Returns: The collected elements (or the result of the expectation).
    • Throws: Throws if the timeout is reached or if the publisher emits an error.
    let recorder = publisher.record()
    let elements = try wait(for: recorder.elements, timeout: 1.0, description: "Elements")
  12. Use `last` to get the final emitted element

    master

    The recorder.last expectation waits for the recorded publisher to complete and returns the last element emitted.

    Key Behaviors:

    • Return Value: Returns the last element, or nil if the publisher completes without emitting any elements.
    • Error Handling:
      • Throws RecordingError.notCompleted if the publisher does not complete on time.
      • Throws the publisher's error if the publisher fails.

    Use this when you only care about the final state/value of a sequence.

    // SUCCESS: no timeout, no error
    func testArrayPublisherPublishesLastElementLast() throws {
        let publisher = ["foo", "bar", "baz"].publisher
        let recorder = publisher.record()
        if let element = try wait(for: recorder.last, timeout: ...) {
            XCTAssertEqual(element, "baz")
        } else {
            XCTFail("Expected one element")
        }
    }