-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
MBL-1017: Add features to CombineTestObserver #1920
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,13 @@ | ||
import Combine | ||
import Foundation | ||
import XCTest | ||
|
||
/** | ||
A wrapper around a subscription that saves all events to a public array so | ||
that assertions can be made on a publisher's behavior. | ||
*/ | ||
public final class CombineTestObserver<Value, Error: Swift.Error> { | ||
/// Represents the state of an event in the publisher's timeline | ||
public enum Event { | ||
case value(Value) | ||
case error(Error) | ||
|
@@ -27,4 +33,161 @@ public final class CombineTestObserver<Value, Error: Swift.Error> { | |
} | ||
.store(in: &self.subscriptions) | ||
} | ||
|
||
/// Get all of the next values emitted by the signal. | ||
public var values: [Value] { | ||
var values: [Value] = [] | ||
for event in self.events { | ||
switch event { | ||
case let .value(v): | ||
values.append(v) | ||
default: break | ||
// do nothing | ||
} | ||
} | ||
|
||
return values | ||
} | ||
|
||
/// Get the last value emitted by the signal. | ||
public var lastValue: Value? { | ||
return self.values.last | ||
} | ||
|
||
/// `true` if at least one `.Next` value has been emitted. | ||
public var didEmitValue: Bool { | ||
return self.values.count > 0 | ||
} | ||
|
||
/// The failed error if the signal has failed. | ||
public var failedError: Error? { | ||
var errors: [Error] = [] | ||
for event in self.events { | ||
switch event { | ||
case let .error(e): | ||
errors.append(e) | ||
default: break | ||
// do nothing | ||
} | ||
} | ||
|
||
assert( | ||
errors.count <= 1, | ||
"I'm pretty sure a Combine publisher can only ever emit one error. If this fails, we've learned something new today." | ||
) | ||
|
||
return errors.last | ||
} | ||
|
||
/// `true` if a `.Failed` event has been emitted. | ||
public var didFail: Bool { | ||
return self.failedError != nil | ||
} | ||
|
||
/// `true` if a `.Finished` event has been emitted or a `.Failed` event has been ommitted | ||
public var didComplete: Bool { | ||
return self.events.contains { event in | ||
switch event { | ||
case .finished: | ||
return true | ||
case .error: | ||
return true | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is one difference. A failed publisher in Combine is 'dead' and complete, whereas I believe that's not true in ReactiveSwift. |
||
|
||
default: break | ||
} | ||
return false | ||
} | ||
} | ||
|
||
public func assertDidComplete(_ message: String = "Should have completed.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All these asserts are, for the most part, copy-pasted. |
||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertTrue(self.didComplete, message, file: file, line: line) | ||
} | ||
|
||
public func assertDidFail(_ message: String = "Should have failed.", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertTrue(self.didFail, message, file: file, line: line) | ||
} | ||
|
||
public func assertDidNotFail(_ message: String = "Should not have failed.", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertFalse(self.didFail, message, file: file, line: line) | ||
} | ||
|
||
public func assertDidNotComplete(_ message: String = "Should not have completed", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertFalse(self.didComplete, message, file: file, line: line) | ||
} | ||
|
||
public func assertDidEmitValue(_ message: String = "Should have emitted at least one value.", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssert(self.values.count > 0, message, file: file, line: line) | ||
} | ||
|
||
public func assertDidNotEmitValue(_ message: String = "Should not have emitted any values.", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertEqual(0, self.values.count, message, file: file, line: line) | ||
} | ||
|
||
public func assertDidTerminate( | ||
_ message: String = "Should have terminated, i.e. completed/failed/interrupted.", | ||
file: StaticString = #file, line: UInt = #line | ||
) { | ||
XCTAssertTrue(self.didFail || self.didComplete, message, file: file, line: line) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I don't think you need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Legit! This is copy-pasted from the original |
||
} | ||
|
||
public func assertDidNotTerminate( | ||
_ message: String = "Should not have terminated, i.e. completed/failed/interrupted.", | ||
file: StaticString = #file, line: UInt = #line | ||
) { | ||
XCTAssertTrue(!self.didFail && !self.didComplete, message, file: file, line: line) | ||
} | ||
|
||
public func assertValueCount(_ count: Int, _ message: String? = nil, | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertEqual( | ||
count, | ||
self.values.count, | ||
message ?? "Should have emitted \(count) values", | ||
file: file, | ||
line: line | ||
) | ||
} | ||
} | ||
|
||
extension CombineTestObserver where Value: Equatable { | ||
public func assertValue(_ value: Value, _ message: String? = nil, | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertEqual(1, self.values.count, "A single item should have been emitted.", file: file, line: line) | ||
XCTAssertEqual( | ||
value, | ||
self.lastValue, | ||
message ?? "A single value of \(value) should have been emitted", | ||
file: file, | ||
line: line | ||
) | ||
} | ||
|
||
public func assertLastValue(_ value: Value, _ message: String? = nil, | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertEqual( | ||
value, | ||
self.lastValue, | ||
message ?? "Last emitted value is equal to \(value).", | ||
file: file, | ||
line: line | ||
) | ||
} | ||
|
||
public func assertValues(_ values: [Value], _ message: String = "", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertEqual(values, self.values, message, file: file, line: line) | ||
} | ||
} | ||
|
||
extension CombineTestObserver where Error: Equatable { | ||
public func assertFailed(_ expectedError: Error, message: String = "", | ||
file: StaticString = #file, line: UInt = #line) { | ||
XCTAssertEqual(expectedError, self.failedError, message, file: file, line: line) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import Combine | ||
import XCTest | ||
|
||
final class ConcreteError: Error { | ||
var message: String | ||
|
||
init(message: String) { | ||
self.message = message | ||
} | ||
} | ||
|
||
final class CombineTestObserverTests: XCTestCase { | ||
var publisher = PassthroughSubject<Bool, ConcreteError>() | ||
var observer = CombineTestObserver<Bool, ConcreteError>() | ||
|
||
override func setUp() { | ||
self.publisher = PassthroughSubject<Bool, ConcreteError>() | ||
self.observer = CombineTestObserver<Bool, ConcreteError>() | ||
|
||
self.observer.observe(self.publisher) | ||
} | ||
|
||
func testValues() { | ||
self.publisher.send(true) | ||
self.observer.assertValue(true) | ||
self.observer.assertDidEmitValue() | ||
|
||
self.publisher.send(false) | ||
self.observer.assertLastValue(false) | ||
|
||
self.observer.assertValues([true, false]) | ||
self.observer.assertValueCount(2) | ||
|
||
self.observer.assertDidNotFail() | ||
self.observer.assertDidNotComplete() | ||
self.observer.assertDidNotTerminate() | ||
} | ||
|
||
func testFailure() { | ||
self.publisher.send(true) | ||
self.observer.assertValue(true) | ||
|
||
let msg = "failure :(" | ||
let error = ConcreteError(message: msg) | ||
|
||
publisher.send(completion: Subscribers.Completion.failure(error)) | ||
|
||
self.observer.assertDidFail() | ||
XCTAssertEqual(self.observer.failedError?.message, msg) | ||
|
||
// n.B. in Combine, a publisher also finishes and cannot continue | ||
// after an error occurs. | ||
self.observer.assertDidComplete() | ||
} | ||
|
||
func testCompletion() { | ||
self.publisher.send(false) | ||
self.observer.assertValue(false) | ||
|
||
self.publisher.send(completion: Subscribers.Completion.finished) | ||
self.observer.assertDidComplete() | ||
self.observer.assertDidNotFail() | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Do we want this check in our codebase? I'm okay with it since I'm kind of curious, but it feels a bit unprofessional/informal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ha, I like throwing a little informality into code sometimes, but if you want me to make the language more neutral I definitely can.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm used to an "avoid we" policy and I do have a slight preference for more neutral language, but I also think this is kind of fun and it's still clear who "we" is. So up to you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting point on "we"! That's one I hadn't heard before.