When releasing Combine, Apple didn’t show us what their vision for testing the codebase with Combine was. Surely they must have thought about it.

Fortunately, the authors of Combine didn’t try to reinvent the wheel. The concepts, they built the framework around, are quite similar to the concepts in other reactive frameworks used on iOS. It means we can reuse the knowledge and best practices that evolved in the existing iOS-reactive-framework communities.

Let’s take RxSwift as (probably) the most used reactive framework of the pre-Combine era. It has a convenient set of tools for use in tests. We’ll use it as inspiration for today.

We’re going to build a simple Combine test helper allowing us to record and synchronously assert values produced by a publisher.

Recorder

Let’s start with defining Recorder. It’s a concrete Subscriber implementation that stores all incoming values and the completion event to its records array.

extension Recorder {
  /// Possible types of records.
  public enum Record {
    case value(Input)
    case completion(Subscribers.Completion<Failure>)
  }
}

/// A subscriber which records all values from the attached publisher.
public class Recorder<Input, Failure: Error> {
  public var records: [Record] = []
}

extension Recorder: Subscriber {
  public func receive(subscription: Subscription) {
    // The number of values we request from the publisher is unlimited.
    subscription.request(.unlimited)
  }

  public func receive(_ input: Input) -> Subscribers.Demand {
    // Because the `receive` function can be called on any thread, we need
    // to always dispatch on the main thread.
    DispatchQueue.main.async { 
      self.records.append(.value(input)) 
    }
    return .unlimited
  }

  public func receive(completion: Subscribers.Completion<Failure>) {
    DispatchQueue.main.async { 
      self.records.append(.completion(completion)) 
    }
  }
}

The Recorder will be initialized with the number of values we want it to record:

  public init(numberOfRecords: Int) {
    assert(numberOfRecords > 0, "numberOfRecords must be greater than zero.")
    self.numberOfRecords = numberOfRecords
  }

  private let numberOfRecords: Int

Producers can (and usually do) produce their values asynchronously. Testing asynchronous code in XCTest can easily become complex and hard to read and maintain.

We’d like to tell Recorder to start recording and wait until the given number of records is recorded. If the attached producer doesn’t provide the number of values the recorder expects within the given time period, the recorder should fail.

 // 1. 
  // We use XCTestExpectation and XCTWaiter to pause 
  // the execution of the program. We don't need to provide 
  // a custom message to the expectation, because we're not 
  // going to surface it to the user.
  private let expectation = XCTestExpectation()
  private let waiter = XCTWaiter()

  /// Pauses the execution of the current test and waits,
  /// until the required number of values is recorded.
  ///
  /// - Parameter timeout: The amount of time within which
  /// the required number of records should be recorded.
  public func waitForAllValues(
    timeout: TimeInterval = 1,
    file: StaticString = #file,
    line: UInt = #line
  ) {

    // 2. 
    // If we already have the number of records we need, we can
    // return and continue without blocking the execution.
    guard records.count < numberOfRecords else { return }

    // 3. 
    // We want to wait until the expectation is fulfilled. This
    // is the very line that actually blocks the further execution
    // of the program.
    let result = waiter.wait(for: [expectation], timeout: timeout)
    
    // 4. 
    // We need to check the result of the waiter. If the expectation
    // wasn't successfully fulfilled, we need to fail the test.
    // Otherwise, we can finally return from this function and continue.
    if result != .completed {
      func valueFormatter(_ count: Int) -> String {
        "\(count) value" + (count == 1 ? "" : "s")
      }

      XCTFail(""" Waiting for \(valueFormatter(numberOfRecords)) timed out. Received only \(valueFormatter(records.count)). """,
        file: file,
        line: line
      )
    }
  }

Finally, we have to add the code which fulfills the expectation when Recorderrecords the number of values it needs:

  public var records: [Record] = [] {
    didSet {
      if numberOfRecords == records.count {
        expectation.fulfill()
      }
    }
  }

Publisher Extension

To make our life easier, we can create a convenient extension of Publisher which creates a new recorder and subscribe it to the given publisher:

extension Publisher {
  /// Creates a new recorder subscribed to this publisher.
  public func record(numberOfRecords: Int) -> Recorder<Output, Failure> {
    let recorder = Recorder<Output, Failure>(numberOfRecords: numberOfRecords)
    subscribe(recorder)
    return recorder
  }
}

Using in Tests

First, we need to make the Recorder.Record type Equatable when possible. Otherwise, we can’t use XCTAssertEqual on it.

extension Recorder.Record: Equatable 
  where Input: Equatable, Failure: Equatable 
{ }

Let’s give it a shot and use Recorder in tests. You’ll be amazed by how simple the tests are!

func testPassthroughSubject() {
    // 1.
    // We create the publisher we want to test.
    let publisher = PassthroughSubject<Int, Never>()

    // 2.
    // We create a new recorder.
    let recorder = publisher.record(numberOfRecords: 2)

    // 3.
    // Let's send the values to the publisher asynchronously
    let queue = DispatchQueue.global(qos: .default)
    queue.asyncAfter(deadline: .now() + 0.1) { publisher.send(1) }
    queue.asyncAfter(deadline: .now() + 0.2) { publisher.send(2) }

    // 4.
    // On this line, the execution of the program pauses.
    recorder.waitForAllValues()

    // 5.
    // Finally, let's assert the recorded values.
    XCTAssertEqual(recorder.records, [.value(1), .value(2)])
  }

What if we send just one value?

Combine publisher blocking recorder

The recorder times out. Great.

Let’s try to send a wrong value:

Combine publisher blocking recorder

Ugh! The error message is quite ugly. Let’s improve that. We provide a custom CustomStringConvertible implementation which will forward the description call to values associated with the cases:

extension Recorder.Record: CustomStringConvertible {
  public var description: String {
    switch self {
    case let .value(inputValue):
      return "\(inputValue)"
    case let .completion(completionValue):
      return "\(completionValue)"
    }
  }
}
Combine publisher blocking recorder

That looks much better!

Custom Assertions

Writing .value every time we want to assert an incoming value can become tedious. We can specify a custom XCTAssert function for the situations where we don’t need to assert on the completion event:

public func XCTAssertRecordedValues<Input: Equatable, Failure: Error>(
  _ recorder: Recorder<Input, Failure>,
  _ expectedValues: [Input],
  file: StaticString = #file,
  line: UInt = #line
) {
  // Get only the values, ignore other records
  let values = recorder.records.compactMap { record -> Input? in
    if case let .value(inputValue) = record {
      return inputValue
    } else {
      return nil
    }
  }
  XCTAssertEqual(values, expectedValues, file: file, line: line)
}

This simplifies the tests even more and allows us the write the final assertion like this:

XCTAssertRecordedValues(recorder, [1, 2])

You can find more info about custom XCTAssert functions in my article about Building a Custom XCTAssert for Multiline Strings.

Ideas

  • What if we move the recorder.waitForAllValues() into the XCTAssertRecordedValues function? It would mean we wouldn’t have to write it manually but it could also feel like “to much magic”:
  • We could go with even more “magic” and pause the execution when we try to access the records value ?. But I guess no one wants so much magic in their APIs.

The source project for this blog post can be found on https://github.com/industrialbinaries/CombineTestExtensions.