Skip to content
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

Merged
merged 3 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,7 @@
E10D06632ACF385E00470B5C /* FetchBackerProjectsQuery.json in Resources */ = {isa = PBXBuildFile; fileRef = E10D06622ACF385E00470B5C /* FetchBackerProjectsQuery.json */; };
E10D06652AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test in Resources */ = {isa = PBXBuildFile; fileRef = E10D06642AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test */; };
E170B9112B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170B9102B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift */; };
E1889D8F2B6065E1004FBE21 /* CombineTestObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1889D8D2B6065D6004FBE21 /* CombineTestObserverTests.swift */; };
E1A1491E2ACDD76800F49709 /* FetchBackerProjectsQuery.graphql in Resources */ = {isa = PBXBuildFile; fileRef = E1A1491D2ACDD76700F49709 /* FetchBackerProjectsQuery.graphql */; };
E1A149202ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1491F2ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift */; };
E1A149222ACE013100F49709 /* FetchProjectsEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */; };
Expand Down Expand Up @@ -3058,6 +3059,7 @@
E10D06622ACF385E00470B5C /* FetchBackerProjectsQuery.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FetchBackerProjectsQuery.json; sourceTree = "<group>"; };
E10D06642AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test */ = {isa = PBXFileReference; lastKnownFileType = text; path = FetchBackerProjectsQueryRequestForTests.graphql_test; sourceTree = "<group>"; };
E170B9102B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockGraphQLClient+CombineTests.swift"; sourceTree = "<group>"; };
E1889D8D2B6065D6004FBE21 /* CombineTestObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestObserverTests.swift; sourceTree = "<group>"; };
E1A1491D2ACDD76700F49709 /* FetchBackerProjectsQuery.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = FetchBackerProjectsQuery.graphql; sourceTree = "<group>"; };
E1A1491F2ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift"; sourceTree = "<group>"; };
E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchProjectsEnvelope.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6875,6 +6877,7 @@
children = (
E1EA34EE2AE1B28400942A04 /* Signal+Combine.swift */,
E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */,
E1889D8D2B6065D6004FBE21 /* CombineTestObserverTests.swift */,
);
path = combine;
sourceTree = "<group>";
Expand Down Expand Up @@ -8637,6 +8640,7 @@
D755ECA92319AF4D0096F189 /* CreateBackingEnvelope.swift in Sources */,
D0158A131EEB30A2006E7684 /* DiscoveryEnvelopeTemplates.swift in Sources */,
06232D452795EC4600A81755 /* TextViewElement.swift in Sources */,
E1889D8F2B6065E1004FBE21 /* CombineTestObserverTests.swift in Sources */,
D01588431EEB2ED7006E7684 /* Activity.swift in Sources */,
8ACF36F7262763960026E74D /* MockPerimeterXTypes.swift in Sources */,
D01588CD1EEB2ED7006E7684 /* ShippingRulesEnvelopeLenses.swift in Sources */,
Expand Down
163 changes: 163 additions & 0 deletions KsApi/combine/CombineTestObserver.swift
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)
Expand All @@ -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, I'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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.",
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I don't think you need self.didFail here - if it failed self.didComplete will return true as well. Same for didNotTerminate below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legit! This is copy-pasted from the original TestObserver so I'm inclined to just leave it for parity.

}

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)
}
}
64 changes: 64 additions & 0 deletions KsApi/combine/CombineTestObserverTests.swift
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()
}
}