CombineExpectations
Utilities for tests that wait for Combine publishers
Install / Use
/learn @groue/CombineExpectationsREADME
Combine Expectations
Utilities for tests that wait for Combine publishers.
Latest release: version 0.10.0 (August 11, 2021) • [Release Notes]
Requirements: iOS 13+, macOS 10.15+, and tvOS 13+ require Swift 5.1+ or Xcode 11+. watchOS 7.4+ requires Swift 5.4+ or Xcode 12.5+.
Contact: Report bugs and ask questions in Github issues.
Testing Combine publishers with XCTestExpectation often requires setting up a lot of boilerplate code.
CombineExpectations aims at streamlining those tests. It defines an XCTestCase method which waits for publisher expectations.
- [Usage]
- [Installation]
- [Publisher Expectations]: [availableElements], [completion], [elements], [finished], [last], [next()], [next(count)], [prefix(maxLength)], [recording], [single]
Usage
Waiting for [Publisher Expectations] allows your tests to look like this:
import XCTest
import CombineExpectations
class PublisherTests: XCTestCase {
func testElements() throws {
// 1. Create a publisher
let publisher = ...
// 2. Start recording the publisher
let recorder = publisher.record()
// 3. Wait for a publisher expectation
let elements = try wait(for: recorder.elements, timeout: ..., description: "Elements")
// 4. Test the result of the expectation
XCTAssertEqual(elements, ["Hello", "World!"])
}
}
When you wait for a publisher expectation:
- The test fails if the expectation is not fulfilled within the specified timeout.
- An error is thrown if the expected value can not be returned. For example, waiting for
recorder.elementsthrows the publisher error if the publisher completes with a failure. - The
waitmethod returns immediately if the expectation has already reached the waited state.
You can wait multiple times for a publisher:
class PublisherTests: XCTestCase {
func testPublisher() throws {
let publisher = ...
let recorder = publisher.record()
// Wait for first element
_ = try wait(for: recorder.next(), timeout: ...)
// Wait for second element
_ = try wait(for: recorder.next(), timeout: ...)
// Wait for successful completion
try wait(for: recorder.finished, timeout: ...)
}
}
Not all tests have to wait, because some publishers expectations are fulfilled right away. In this case, prefer the synchronous get() method over wait(for:timeout:), as below:
class PublisherTests: XCTestCase {
func testSynchronousPublisher() throws {
// 1. Create a publisher
let publisher = ...
// 2. Start recording the publisher
let recorder = publisher.record()
// 3. Grab the expected result
let elements = try recorder.elements.get()
// 4. Test the result of the expectation
XCTAssertEqual(elements, ["Hello", "World!"])
}
}
Just like wait(for:timeout:), the get() method can be called multiple times:
class PublisherTests: XCTestCase {
// SUCCESS: no error
func testPassthroughSubjectSynchronouslyPublishesElements() throws {
let publisher = PassthroughSubject<String, Never>()
let recorder = publisher.record()
publisher.send("foo")
try XCTAssertEqual(recorder.next().get(), "foo")
publisher.send("bar")
try XCTAssertEqual(recorder.next().get(), "bar")
}
}
Installation
Add a dependency for CombineExpectations to your Swift Package test targets:
import PackageDescription
let package = Package(
dependencies: [
+ .package(url: "https://github.com/groue/CombineExpectations.git", ...)
],
targets: [
.testTarget(
dependencies: [
+ "CombineExpectations"
])
]
)
Publisher Expectations
There are various publisher expectations. Each one waits for a specific publisher aspect:
- [availableElements]: all published elements until timeout expiration
- [completion]: the publisher completion
- [elements]: all published elements until successful completion
- [finished]: the publisher successful completion
- [last]: the last published element
- [next()]: the next published element
- [next(count)]: the next N published elements
- [prefix(maxLength)]: the first N published elements
- [recording]: the full recording of publisher events
- [single]: the one and only published element
availableElements
:clock230: recorder.availableElements waits for the expectation to expire, or the recorded publisher to complete.
:x: When waiting for this expectation, the publisher error is thrown if the publisher fails before the expectation has expired.
:white_check_mark: Otherwise, an array of all elements published before the expectation has expired is returned.
:arrow_right: Related expectations: [elements], [prefix(maxLength)].
Unlike other expectations, availableElements does not make a test fail on timeout expiration. It just returns the elements published so far.
Example:
// 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)
}
completion
:clock230: recorder.completion waits for the recorded publisher to complete.
:x: When waiting for this expectation, a RecordingError.notCompleted is thrown if the publisher does not complete on time.
:white_check_mark: Otherwise, a Subscribers.Completion is returned.
:arrow_right: Related expectations: [finished], [recording].
Example:
// 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)")
}
}
// SUCCESS: no error
func testArrayPublisherSynchronouslyCompletesWithSuccess() throws {
let publisher = ["foo", "bar", "baz"].publisher
let recorder = publisher.record()
let completion = try recorder.completion.get()
if case let .failure(error) = completion {
XCTFail("Unexpected error \(error)")
}
}
<details>
<summary>Examples of failing tests</summary>
// FAIL: Asynchronous wait failed
// FAIL: Caught error RecordingError.notCompleted
func testCompletionTimeout() throws {
let publisher = PassthroughSubject<String, Never>()
let recorder = publisher.record()
let completion = try wait(for: recorder.completion, timeout: ...)
}
</details>
elements
:clock230: recorder.elements waits for the recorded publisher to complete.
:x: When waiting for this expectation, a RecordingError.notCompleted is thrown if the publisher does not complete on time, and the publisher error is thrown if the publisher fails.
:white_check_mark: Otherwise, an array of published elements is returned.
:arrow_right: Related expectations: [availableElements], [last], [prefix(maxLength)], [recording], [single].
Example:
// 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"])
}
// SUCCESS: no error
func testArrayPublisherSynchronouslyPublishesArrayElements() throws {
let publisher = ["foo", "bar", "baz"].publisher
let recorder = publisher.record()
let elements = try recorder.elements.get()
XCTAssertEqual(elements, ["foo", "bar", "baz"])
}
<details>
<summary>Examples of failing tests</summary>
// FAIL: Asynchronous wait failed
// FAIL: Caught error RecordingError.notCompleted
func testElementsTimeout() throws {
let publisher = PassthroughSubject<String, Never>()
let recorder = publisher.record()
let elements = try wait(for: recorder.elements, timeout: ...)
}
// FAIL: Caught error MyError
func testElementsError() throws {
let publisher = PassthroughSubject<String, MyError>()
let recorder = publisher.record()
publisher.send(completion: .failure(MyError()))
let elements = try wait(for: recorder.elements, timeout: ...)
}
</details>
finished
:clock230: recorder.finished waits for the recorded publisher to complete.
:x: When waiting for this expectation, the publisher error is thrown if the publisher fails.
:arrow_right: Related expectations: [completion], [recording].
Example:
// SUCCESS: no timeout, no error
func testArrayPublisherFinishesWithoutError() throws {
let publisher = ["foo", "bar", "baz"].publisher
let recorder = publisher.record()
try wait(for: recorder.finished, timeout: ...)
}
// SUCCESS: no error
func testArrayPublisherSynchronouslyFinishesWithoutError() throws {
let publisher = ["foo", "bar", "baz"].publisher
let recorder = publisher.record()
try recorder.finished.get()
}
<details>
<summary>Examples of failing tests</summary>
// FAIL: Asynchronous wait failed
func testFinishedTimeout() throws {
let publisher = PassthroughSubject<String, Never>()
let recorder = publisher.record()
try wait(for: recorder.finished, timeout: ...)
}
// FAIL: Caught err
