diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b18231..38348d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ All notable changes to this project will be documented in this file. - [0.2.0](#020) - [0.1.0](#010) +## Development Branch + +- **New**: `availableElements` expectation ## 0.5.0 diff --git a/README.md b/README.md index ce63627..d691cac 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ CombineExpectations aims at streamlining those tests. It defines an XCTestCase m - [Usage] - [Installation] -- [Publisher Expectations]: [completion], [elements], [finished], [last], [next()], [next(count)], [prefix(maxLength)], [recording], [single] +- [Publisher Expectations]: [availableElements], [completion], [elements], [finished], [last], [next()], [next(count)], [prefix(maxLength)], [recording], [single] --- @@ -112,7 +112,6 @@ class PublisherTests: XCTestCase { ``` - ## Installation Add a dependency for CombineExpectations to your [Swift Package](https://swift.org/package-manager/) test targets: @@ -138,6 +137,7 @@ Add a dependency for CombineExpectations to your [Swift Package](https://swift.o 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 @@ -150,6 +150,30 @@ There are various publisher expectations. Each one waits for a specific publishe --- +### 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: + +```swift +// 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. @@ -210,7 +234,7 @@ func testCompletionTimeout() throws { :white_check_mark: Otherwise, an array of published elements is returned. -:arrow_right: Related expectations: [last], [prefix(maxLength)], [recording], [single]. +:arrow_right: Related expectations: [availableElements], [last], [prefix(maxLength)], [recording], [single]. Example: @@ -588,7 +612,7 @@ func testNextCountNotEnoughElementsError() throws { :white_check_mark: Otherwise, an array of received elements is returned, containing at most `maxLength` elements, or less if the publisher completes early. -:arrow_right: Related expectations: [elements], [next(count)]. +:arrow_right: Related expectations: [availableElements], [elements], [next(count)]. Example: @@ -817,3 +841,4 @@ func testSingleNotEnoughElementsError() throws { [elements]: #elements [last]: #last [single]: #single +[availableElements]: #availableElements diff --git a/Sources/CombineExpectations/PublisherExpectation.swift b/Sources/CombineExpectations/PublisherExpectation.swift index 51bcf79..fbf1bab 100644 --- a/Sources/CombineExpectations/PublisherExpectation.swift +++ b/Sources/CombineExpectations/PublisherExpectation.swift @@ -5,10 +5,21 @@ public enum PublisherExpectations { } /// The base protocol for PublisherExpectation. It is an implementation detail /// that you are not supposed to use, as shown by the underscore prefix. +/// +/// :nodoc: public protocol _PublisherExpectationBase { /// Sets up an XCTestExpectation. This method is an implementation detail /// that you are not supposed to use, as shown by the underscore prefix. func _setup(_ expectation: XCTestExpectation) + + /// Returns an object that waits for the expectation. If nil, expectation + /// is waited by the XCTestCase. + func _makeWaiter() -> XCTWaiter? +} + +extension _PublisherExpectationBase { + /// :nodoc: + public func _makeWaiter() -> XCTWaiter? { nil } } /// The protocol for publisher expectations. @@ -93,7 +104,11 @@ extension XCTestCase { { let expectation = self.expectation(description: description) publisherExpectation._setup(expectation) - wait(for: [expectation], timeout: timeout) + if let waiter = publisherExpectation._makeWaiter() { + waiter.wait(for: [expectation], timeout: timeout) + } else { + wait(for: [expectation], timeout: timeout) + } return try publisherExpectation.get() } } diff --git a/Sources/CombineExpectations/PublisherExpectations/AvailableElements.swift b/Sources/CombineExpectations/PublisherExpectations/AvailableElements.swift new file mode 100644 index 0000000..3de5bfe --- /dev/null +++ b/Sources/CombineExpectations/PublisherExpectations/AvailableElements.swift @@ -0,0 +1,59 @@ +import XCTest + +extension PublisherExpectations { + /// A publisher expectation which waits for the timeout to expire, or + /// the recorded publisher to complete. + /// + /// When waiting for this expectation, the publisher error is thrown if + /// the publisher fails before the expectation has expired. + /// + /// Otherwise, an array of all elements published before the expectation + /// has expired is returned. + /// + /// Unlike other expectations, `AvailableElements` does not make a test fail + /// on timeout expiration. It just returns the elements published so far. + /// + /// For 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) + /// } + public struct AvailableElements: PublisherExpectation { + let recorder: Recorder + + public func _makeWaiter() -> XCTWaiter? { Waiter() } + + public func _setup(_ expectation: XCTestExpectation) { + recorder.fulfillOnCompletion(expectation) + } + + /// Returns all elements published so far, or throws an error if the + /// publisher has failed. + public func get() throws -> [Input] { + try recorder.value { (elements, completion, remainingElements, consume) in + if case let .failure(error) = completion { + throw error + } + consume(remainingElements.count) + return elements + } + } + + /// A waiter that waits but never fails + private class Waiter: XCTWaiter, XCTWaiterDelegate { + init() { + super.init(delegate: nil) + delegate = self + } + + func waiter(_ waiter: XCTWaiter, didTimeoutWithUnfulfilledExpectations unfulfilledExpectations: [XCTestExpectation]) { } + func waiter(_ waiter: XCTWaiter, fulfillmentDidViolateOrderingConstraintsFor expectation: XCTestExpectation, requiredExpectation: XCTestExpectation) { } + func waiter(_ waiter: XCTWaiter, didFulfillInvertedExpectation expectation: XCTestExpectation) { } + func nestedWaiter(_ waiter: XCTWaiter, wasInterruptedByTimedOutWaiter outerWaiter: XCTWaiter) { } + } + } +} diff --git a/Sources/CombineExpectations/Recorder.swift b/Sources/CombineExpectations/Recorder.swift index 60d79ff..8af2e4f 100644 --- a/Sources/CombineExpectations/Recorder.swift +++ b/Sources/CombineExpectations/Recorder.swift @@ -274,20 +274,45 @@ public class Recorder: Subscriber { // MARK: - Publisher Expectations extension PublisherExpectations { - /// The type of the publisher expectation returned by Recorder.completion + /// The type of the publisher expectation returned by `Recorder.completion`. public typealias Completion = Map, Subscribers.Completion> - /// The type of the publisher expectation returned by Recorder.elements + /// The type of the publisher expectation returned by `Recorder.elements`. public typealias Elements = Map, [Input]> - /// The type of the publisher expectation returned by Recorder.last + /// The type of the publisher expectation returned by `Recorder.last`. public typealias Last = Map, Input?> - /// The type of the publisher expectation returned by Recorder.single + /// The type of the publisher expectation returned by `Recorder.single`. public typealias Single = Map, Input> } extension Recorder { + /// Returns a publisher expectation which waits for the timeout to expire, + /// or the recorded publisher to complete. + /// + /// When waiting for this expectation, the publisher error is thrown if + /// the publisher fails before the expectation has expired. + /// + /// Otherwise, an array of all elements published before the expectation + /// has expired is returned. + /// + /// Unlike other expectations, `availableElements` does not make a test fail + /// on timeout expiration. It just returns the elements published so far. + /// + /// For 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) + /// } + public var availableElements: PublisherExpectations.AvailableElements { + PublisherExpectations.AvailableElements(recorder: self) + } + /// Returns a publisher expectation which waits for the recorded publisher /// to complete. /// diff --git a/Tests/CombineExpectationsTests/RecorderTests.swift b/Tests/CombineExpectationsTests/RecorderTests.swift index fba7109..fdca65c 100644 --- a/Tests/CombineExpectationsTests/RecorderTests.swift +++ b/Tests/CombineExpectationsTests/RecorderTests.swift @@ -16,6 +16,59 @@ class RecorderTests: XCTestCase { XCTAssertTrue(subscribed) } + // MARK: - availableElements + + func testAvailableElementsSync() throws { + do { + let publisher = [1, 2, 3].publisher + let recorder = publisher.record() + let availableElements = try recorder.availableElements.get() + XCTAssertEqual(availableElements, [1, 2, 3]) + } + do { + let publisher = PassthroughSubject() + let recorder = publisher.record() + publisher.send(1) + publisher.send(2) + publisher.send(3) + let availableElements = try recorder.availableElements.get() + XCTAssertEqual(availableElements, [1, 2, 3]) + } + do { + let publisher = PassthroughSubject() + let recorder = publisher.record() + publisher.send(1) + publisher.send(completion: .failure(TestError())) + _ = try recorder.availableElements.get() + XCTFail("Expected TestError") + } catch is TestError { } + } + + func testAvailableElementsAsync() throws { + do { + let publisher = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() + let recorder = publisher.record() + let dates = try wait(for: recorder.availableElements, timeout: 0.1) + XCTAssertTrue(dates.count > 2) + XCTAssertEqual(dates.sorted(), dates) + } + } + + func testAvailableElementsStopsOnPublisherCompletion() throws { + do { + let publisher = PassthroughSubject() + let recorder = publisher.record() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + publisher.send(completion: .finished) + } + let start = Date() + _ = try wait(for: recorder.availableElements, timeout: 2) + let duration = Date().timeIntervalSince(start) + XCTAssertLessThan(duration, 1) + } + } + // MARK: - elementsAndCompletion func testElementsAndCompletionSync() throws {