tcldr / entwine Goto Github PK
View Code? Open in Web Editor NEWTesting tools and utilities for Apple's Combine framework.
License: MIT License
Testing tools and utilities for Apple's Combine framework.
License: MIT License
I'm tinkering with persistence middleware for an app which uses Redux-like architecture using Realm as a backend. Results from queries on the database should be emitted by a Combine Publisher.
Although Realm now supports Combine and provides FrozenCollection types to manage threading and concurrency-related errors, I've been advised to use DTOs (structs) to make things as predictable as possible. I've written a test which utilises Entwine to ensure that the conversion of the Realm FrozenCollection to the DTO behaves as expected but the TestableSubscriber receives an empty array at time index 0 when it should in fact receive three inputs (initial empty array at t=0, test set of 3 elements at t = 100 and a second test set of 3 elements at t = 200).
I've reverted to testing using an Expectation as well as debugging the Publisher using print() and get the expected output (6 elements emitted in total, conversion to DTO working OK) so am working on the assumption that I am doing something wrong although I guess there is the outside possibility there's a bug?
Currently using Xcode12, iOS14. Code as follows:
Middleware (SUT)
import Foundation
import Combine
import RealmSwift
final class PersistenceMiddleware {
private var cancellables = Set<AnyCancellable>()
private var subject = CurrentValueSubject<[ToDo.DTO], Never>([])
func allToDos(in realm: Realm = try! Realm()) -> AnyPublisher<[ToDo.DTO], Never> {
realm.objects(ToDo.self)
.collectionPublisher
// .print()
.assertNoFailure()
.freeze()
.map { item in
item.map { $0.convertToDTO() }
}
// .print()
.receive(on: DispatchQueue.main)
.subscribe(subject)
.store(in: &cancellables)
return subject.eraseToAnyPublisher()
}
deinit {
print("Deinit")
cancellables = []
}
}
Test class
import XCTest
import Combine
import RealmSwift
import EntwineTest
@testable import SwiftRex_ToDo_Persisted
class SwiftRex_ToDo_PersistedTests: XCTestCase {
private var realm: Realm?
private var testSet1: [ToDo] {
[
ToDo(name: "Mow lawn"),
ToDo(name: "Wash car"),
ToDo(name: "Clean windows")
]
}
private var testSet2: [ToDo] {
[
ToDo(name: "Walk dog"),
ToDo(name: "Cook dinner"),
ToDo(name: "Pay bills")
]
}
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
realm = try! Realm(configuration: Realm.Configuration(inMemoryIdentifier: UUID().uuidString))
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
realm = nil
}
func testCorrectNumberOfObjectsStoredInRealm() {
realm!.addForTesting(objects: testSet1)
XCTAssertEqual(realm!.objects(ToDo.self).count, 3)
realm!.addForTesting(objects: testSet2)
XCTAssertEqual(realm!.objects(ToDo.self).count, 6)
}
func testMiddlewarePublisherUsingEntwine() {
let middleware = PersistenceMiddleware()
let scheduler = TestScheduler()
let subscriber = scheduler.createTestableSubscriber([ToDo.DTO].self, Never.self)
middleware.allToDos(in: self.realm!)
.subscribe(subscriber)
scheduler.schedule(after: 100) { self.realm!.addForTesting(objects: self.testSet1) }
scheduler.schedule(after: 200) { self.realm!.addForTesting(objects: self.testSet2) }
scheduler.resume()
print("\(subscriber.recordedOutput)")
}
func testMiddlewarePublisherUsingExpectation() {
let middleware = PersistenceMiddleware()
var cancellables = Set<AnyCancellable>()
let receivedValues = expectation(description: "received expected number of published objects")
middleware.allToDos(in: realm!)
.sink { result in
if result.count == 6 {
NSLog(result.debugDescription)
receivedValues.fulfill()
}
}
.store(in: &cancellables)
realm!.addForTesting(objects: testSet1)
realm!.addForTesting(objects: testSet2)
waitForExpectations(timeout: 1, handler: nil)
}
}
Console output
Test Suite 'All tests' started at 2020-10-03 15:49:39.166
Test Suite 'SwiftRex-ToDo-PersistedTests.xctest' started at 2020-10-03 15:49:39.167
Test Suite 'SwiftRex_ToDo_PersistedTests' started at 2020-10-03 15:49:39.167
Test Case '-[SwiftRex_ToDo_PersistedTests.SwiftRex_ToDo_PersistedTests testCorrectNumberOfObjectsStoredInRealm]' started.
2020-10-03 15:49:39.182753+0100 SwiftRex-ToDo-Persisted[14502:643475] Version 5.4.7 of Realm is now available: https://github.com/realm/realm-cocoa/blob/v5.4.7/CHANGELOG.md
2020-10-03 15:49:39.231129+0100 SwiftRex-ToDo-Persisted[14502:643297] Setup
2020-10-03 15:49:39.232989+0100 SwiftRex-ToDo-Persisted[14502:643297] Tear down/n
Test Case '-[SwiftRex_ToDo_PersistedTests.SwiftRex_ToDo_PersistedTests testCorrectNumberOfObjectsStoredInRealm]' passed (0.067 seconds).
Test Case '-[SwiftRex_ToDo_PersistedTests.SwiftRex_ToDo_PersistedTests testMiddlewarePublisherUsingEntwine]' started.
2020-10-03 15:49:39.262702+0100 SwiftRex-ToDo-Persisted[14502:643297] Setup
2020-10-03 15:49:39.264956+0100 SwiftRex-ToDo-Persisted[14502:643474] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed
TestSequence<Array, Never>(contents: [(0, .subscribe), (0, .input([]))])
Deinit
2020-10-03 15:49:39.267422+0100 SwiftRex-ToDo-Persisted[14502:643297] Tear down/n
Test Case '-[SwiftRex_ToDo_PersistedTests.SwiftRex_ToDo_PersistedTests testMiddlewarePublisherUsingEntwine]' passed (0.033 seconds).
Test Case '-[SwiftRex_ToDo_PersistedTests.SwiftRex_ToDo_PersistedTests testMiddlewarePublisherUsingExpectation]' started.
2020-10-03 15:49:39.272248+0100 SwiftRex-ToDo-Persisted[14502:643297] Setup
2020-10-03 15:49:39.274820+0100 SwiftRex-ToDo-Persisted[14502:643297] [SwiftRex_ToDo_Persisted.ToDo.DTO(id: 0, name: "Mow lawn"), SwiftRex_ToDo_Persisted.ToDo.DTO(id: 1, name: "Wash car"), SwiftRex_ToDo_Persisted.ToDo.DTO(id: 2, name: "Clean windows"), SwiftRex_ToDo_Persisted.ToDo.DTO(id: 3, name: "Walk dog"), SwiftRex_ToDo_Persisted.ToDo.DTO(id: 4, name: "Cook dinner"), SwiftRex_ToDo_Persisted.ToDo.DTO(id: 5, name: "Pay bills")]
Deinit
2020-10-03 15:49:39.275235+0100 SwiftRex-ToDo-Persisted[14502:643297] Tear down/n
Test Case '-[SwiftRex_ToDo_PersistedTests.SwiftRex_ToDo_PersistedTests testMiddlewarePublisherUsingExpectation]' passed (0.008 seconds).
Test Suite 'SwiftRex_ToDo_PersistedTests' passed at 2020-10-03 15:49:39.277.
Executed 3 tests, with 0 failures (0 unexpected) in 0.108 (0.109) seconds
Test Suite 'SwiftRex-ToDo-PersistedTests.xctest' passed at 2020-10-03 15:49:39.277.
Executed 3 tests, with 0 failures (0 unexpected) in 0.108 (0.110) seconds
Test Suite 'All tests' passed at 2020-10-03 15:49:39.292.
Executed 3 tests, with 0 failures (0 unexpected) in 0.108 (0.126) seconds
First off - I'm the last to say I'm an expert at Swift, or the vagaries of type theory and effectively using a statically type language with generics like we have.
I was creating some tests to using EntwineTests virtual scheduler to illuminate how some of the Combine operators work - specifically combineLatest and some of the others that merge pipelines together. (ref: heckj/swiftui-notes#56)
What I hit my shins on was that the equatable conformance with Entwine's Signal relies entirely on the underlying type's conformance - totally good, except that the type can be a tuple (and is, in the case of combineLatest, which is merging the output types of the upstream pipelines.
I came up with two options to do the validation regardless, although both are really quite ugly hacks. I'm opening this issue to see if you had any brilliant insights into how this might be done more nicely.
The first was the use the same testing hack I use for comparing Error enums - using the debugDescription()
method on the enumeration to generate a string, and comparing that. It's stringly-type-erasing the compiler details away, while providing fairly useful tests.
The second was to create a one-off function that specifically broke down a TestSequence item (itself a tuple of VirtualTime and a Signal enum instance) and return a boolean value if they match up.
Although it's functioning and correct, it feels ugly and awkward, so I was hoping you might have some ideas on how this might be made more elegant.
Although the timing was very useful to show for my combineLatest tests, I also realized that the key of what I was interested in validating was the ordered list of data results, and a lot of the type information around it wasn't as interesting for my specific test.
I'm not sure if there'd be significant value in making a helper operator to pull out just a sequence of the OutputType and make that available from under TestSequence, but it might be useful as an idea.
We are testing a viewModel with a boolean property
@Published var isShown: Bool = false
This value is set by a publisher to which the viewModel subscribes on the Runloop.main
service
.observe()
.receive(on: RunLoop.main)
.sink(receiveCompletion: { _ in })
{ toast in
if let toast = toast {
self.message = toast.message
self.isShown = true
} else {
self.message = ""
self.isShown = false
}
This causes our tests to not receive any update.
let scheduler = TestScheduler()
let results = scheduler.start {
self.underTest.$isShown
}.recordedOutput
expect(results).to(equal(
[
(200, .subscription),
(200, .input(false)),
(300, .input(true)),
(400, .input(false)),
]
))
If we remove .receive(on: RunLoop.main)
the test succeeds.
Is there any chance to add the ability to observe the test on a different thread? Or is there a mistake in our test setup?
RxSwift includes and useful operator that transform any observable into a blocking sequence.
For Combine it'll be really useful being able to implement the same behavior. Here's how it works on RxSwift http://rx-marin.com/post/rxblocking-part1/
I got something working using current code but it's far from ready, it may help us getting the idea of the expected behavior:
let testScheduler = TestScheduler()
let publisher = TestPublisher<Int, TestError>.init { subscriber in
subscriber.receive(1)
subscriber.receive(2)
}
let configuration = TestScheduler.Configuration(pausedOnStart: false, created: 0, subscribed: 0, cancelled: 1, subscriberOptions: TestableSubscriberOptions(initialDemand: Subscribers.Demand.unlimited, subsequentDemand: Subscribers.Demand.unlimited, demandReplenishmentDelay: 0, negativeBalanceHandler: { }))
let testableSubscriber = testScheduler.start(configuration: configuration) { publisher }
XCTAssertEqual(testableSubscriber.sequence, [
(0, .input(1)),
(0, .input(2)),
(0, .subscription),
])
The expected behavior would be:
let publisher = TestPublisher<Int, TestError>.init { subscriber in
subscriber.receive(1)
subscriber.receive(2)
}
let blockingPublisher = publisher.toBlocking()
XCTAssertEqual(blockingPublisher.sequence, [1, 2])
This will allow testing Combine publishers in libraries like https://github.com/bitomule/CombineRealm
Here's the RxSwift implementation: https://github.com/ReactiveX/RxSwift/tree/master/RxBlocking
Xcode 12.5 running on Macbook Pro M1 (Big Sur)
Added Entwine v0.9.1 via Swift Package Manager
sample usage:
import Foundation
import Combine
import Entwine
final class Foo {
....
....
}
Error pops up when building (target device: iPhone 12 (14.5) simulator)
Tried:
Any iOS Simulator SDK
with value arm64
inside Excluded Architecture
I've started to test various Combine Publishers and got stuck with Publishers.CollectByTime
. I'm using TestScheduler
and VirtualTimeInterval
to test it. I might got it all wrong, but I expect the publisher to buffer and periodically publish arrays of signals. The first array is published correctly and it respects the VirtualTimeInterval
. But then ALL other signals are published in the second array and VirtualTime
of each signal is ignored.
With VirtualTimeInterval = 2
I publish
(1, .input("a")),
(2, .input("b")),
(4, .input("c")),
(5, .input("d")),
(7, .input("e")),
(8, .input("f"))
I expect Publishers.CollectByTime
to group signals this way: ["ab", "cd", "ef"]
. But I get ["ab", "cdef"]
.
Can you please tell me what is wrong with my code or maybe with my understanding the CollectByTime
publisher?
import Combine
import Entwine
import EntwineTest
import XCTest
final class CollectByTime: XCTestCase {
func test_common_behavior() {
let configuration = TestScheduler.Configuration.default
let scheduler = TestScheduler()
let upstream: TestablePublisher<String, Never> = scheduler.createRelativeTestablePublisher([
(1, .input("a")),
(2, .input("b")),
(4, .input("c")),
(5, .input("d")),
(7, .input("e")),
(8, .input("f")),
(9, .completion(.finished))
])
let window: VirtualTimeInterval = 2
let strategy = Publishers.TimeGroupingStrategy<TestScheduler>
.byTime(scheduler, window)
let publisher = Publishers.CollectByTime(
upstream: upstream,
strategy: strategy,
options: nil
)
let subscriber = scheduler.start(configuration: configuration, create: { publisher })
let values = subscriber
.recordedOutput
.compactMap({ _, signal -> String? in
switch signal {
case .input(let array):
return array.joined()
default:
return nil
}
})
XCTAssertEqual(values, ["ab", "cd", "ef"])
}
}
Hi!
Consider the following code:
cancellable = Just(2).map { x in
Just(x * x).delay(for: 2.0, scheduler: RunLoop.main)
}
.switchToLatest()
.sink(receiveCompletion: {_ in
print("completed")
}, receiveValue: {result in
print(result)
})
In this example I tried to mimic switchMap operator with map+switchToLatest and surprisingly none of sink callbacks will be ever called. This is a bug in Combine I believe.
Looks like switchToLatest cancels immediately when upstream completes and doesn't wait for inner subscription to complete.
Surprisingly the same example works with a flatMap.
I suggest to write a proper switchToLatest operator and switchMap as well. Here's a code I created today for you to consider. Maybe there's an easier solution?
extension Publisher where Self.Output : Publisher, Self.Output.Failure == Failure {
func switchToLatestWaitable() -> Publishers.SwitchToLatestWaitable<Self> {
Publishers.SwitchToLatestWaitable(upstream: self)
}
}
extension Publishers {
public struct SwitchToLatestWaitable<Upstream: Publisher>: Publisher where Upstream.Output : Publisher, Upstream.Output.Failure == Upstream.Failure {
public typealias Output = Upstream.Output.Output
public typealias Failure = Upstream.Failure
private let upstream: Upstream
init(upstream: Upstream) {
self.upstream = upstream
}
public func receive<S: Subscriber>(subscriber: S) where Failure == S.Failure, Output == S.Input {
upstream.receive(subscriber: SwitchToLatestWaitableSubscriber(subscriber))
}
class SwitchToLatestWaitableSubscriber<Downstream: Subscriber>: Subscriber where Downstream.Input == Upstream.Output.Output, Downstream.Failure == Upstream.Failure {
public typealias Input = Upstream.Output
public typealias Failure = Upstream.Failure
private var downstream: Downstream
private var upstreamCompleted = false
private var downstreamCompleted = false
private var innerCancellable: AnyCancellable? = nil
init(_ downstream: Downstream){
self.downstream = downstream
}
func receive(subscription: Subscription) {
downstream.receive(subscription: subscription)
}
func receive(_ input: Input) -> Subscribers.Demand {
innerCancellable = input.sink(receiveCompletion: {completion in
lock(obj: self) {
switch completion {
case .finished:
if self.upstreamCompleted {
self.complete(completion)
}
case .failure:
self.complete(completion)
}
}
}, receiveValue: {input in
lock(obj: self) {
_ = self.downstream.receive(input)
}
})
return .unlimited
}
func receive(completion: Subscribers.Completion<Failure>) {
lock(obj: self){
switch completion {
case .finished:
upstreamCompleted = true
case .failure:
// Immediately pass failure
complete(completion)
}
}
}
private func complete(_ completion: Subscribers.Completion<Failure>){
if !downstreamCompleted {
downstream.receive(completion: completion)
downstreamCompleted = true
innerCancellable = nil
}
}
}
}
}
Hello again!
Please consider the following code in RxJava:
val subj = BehaviorSubject.createDefault(0)
val stream = subj.switchMap {
print("Inside map\n")
Observable.just(1)
}.replay(1).refCount()
val disposable1 = stream.subscribe {
print("Stream 1 $it\n")
}
disposable1.dispose()
val disposable2 = stream.subscribe {
print("Stream 2 $it\n")
}
disposable2.dispose()
It outputs:
Inside map
Stream 1 1
Inside map
Stream 2 1
Now let's check how Combine+Entwine works:
let source = CurrentValueSubject<Bool, Never>(true)
let stream = source.map{ _ -> AnyPublisher<Int, Never> in
print("Inside map")
return Just(1).eraseToAnyPublisher()
}.switchToLatest()
.share(replay: 1);
let disposable1 = stream.sink(receiveValue: {
print("Stream 1 \($0)")
})
disposable1.cancel()
let disposable2 = stream.sink(receiveValue: {
print("Stream 2 \($0)")
} )
disposable2.cancel()
It outputs:
Inside map
Stream 1 1
Stream 2 1
Inside map
Stream 2 1
Here's the difference: Stream 2 received values twice which is wrong I believe.
This works properly if Source completes.
I believe the difference is that Combine doesn't recreate a Subject in case there are no subscribers left but Source is not completed.
Though RxJava uses new Subject in this case.
I suggest to clean data inside Subject when there are no subscribers left to be consistent.
Wanted to give you a head's up - ReplaySubject no longer conforms to protocol Subject in beta5, as an additional function was added to the protocol. I'm not actively using it, but in pulling in Entwine - master branch - with Xcode, it shows the compilation failure.
I tried to play with examples you cited in documentation and the testMap() example failed for me .
the program stop here :
func request(_ demand: Subscribers.Demand) { _ = queue?.requestDemand(demand) }
I'm using xcode 11.2.1
I cloned the repo and immediately tried:
swift build
swift test
it builds just fine, but on MacOS 10.14.5 (Mojave), the swift test fails, even with the correct swift being invoked:
swift -v
:
Apple Swift version 5.1 (swiftlang-1100.0.43.3 clang-1100.0.26.3)
Target: x86_64-apple-darwin18.6.0
/Users/heckj/Applications/Xcode-beta.app/Contents/Developer/usr/bin/lldb "--repl=-enable-objc-interop -sdk /Users/heckj/Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk -color-diagnostics"
And the failure: swift test
2019-07-01 21:44:52.259 xctest[9733:44482] The bundle “EntwinePackageTests.xctest” couldn’t be loaded because it is damaged or missing necessary resources. Try reinstalling the bundle.
2019-07-01 21:44:52.259 xctest[9733:44482] (dlopen_preflight(/Users/heckj/src/Entwine/.build/x86_64-apple-macosx/debug/EntwinePackageTests.xctest/Contents/MacOS/EntwinePackageTests): Library not loaded: /System/Library/Frameworks/Combine.framework/Versions/A/Combine
Referenced from: /Users/heckj/src/Entwine/.build/x86_64-apple-macosx/debug/EntwinePackageTests.xctest/Contents/MacOS/EntwinePackageTests
Reason: image not found)
This may be something where swift test isn't respecting the platform constraint that you have defined in Package.swift (.macOS(.v10_15)
), but I wasn't entire sure - so thought I'd mention it here.
I just updated Xcode to the 11.2 release, and while I suspect you already know, it seems there's some quirks that make Entwine a little less effective. Tests that passed previously are exploding now - I'm getting an EXC_BAD_ACCESS
on TestablePublisherSubscription - I wasn't sure how to capture the information to best share, so I grabbed a screenshot Xcode dropping into the debugger. I'll poke a bit more, as maybe it's something stupid I've done - but I suspect it's in the queue'd side effects of Xcode 11.2 release
In using TestSubscriber, I found the property name sequence
to be confusing. While it makes sense in the framing of what's been collection, and that it's an ordered list, I immediately confused it for Combine's Sequence.
I wanted to suggest that perhaps a different name would be easier to work with - although I'm lacking on brilliant ideas. My fogged brain only really came up with results
or orderedResults
as alternative property names.
Consider the following code involving ReplaySubject:
// let stream = Just(1).print("Debug: ")
let stream = Just(1).print("Debug: ").share(replay: 1)
let subscription1 = stream.sink(receiveValue: {
print("Receive: \($0)")
})
subscription1.cancel()
let subscription2 = stream.sink(receiveValue: {
print("Receive: \($0)")
})
subscription2.cancel()
Only subscription1 will receive values. This is quite clear because ReplaySubject goes to complete state when uplink completes.
Issue 1:
All late subscribers will receive nothing but completion.
Issue 2:
Though internally ReplaySubject keeps references to data in buffer which will never be accessed by late subscribers and I would consider it a memory leak.
I believe ReplaySubject should behave differently:
When there are no subscribers we should disconnect upstream and cleanup buffer. Thus we get back to absolutely cold state how it was before first subscription. And data which is not accessible will be dereferenced.
When new subscriber comes we connect upstream and start from scratch.
When upstream completes we shouldn't propagate completion but still serve data from buffer to late subscribers.
I don't know how it works in RxSwift but in RxJava:
.replay(1).refcount()
Works just how I described it.
In RxJs it's quite complex and you need to write:
.multicast(() => new ReplaySubject(1)).refCount()
I think since RxJS 6.4.0 it's equal to:
.shareReplay({refCount: true, bufferSize: 1})
In both cases when there are no subscribers data inside subject will be dereferenced and upstream will be cancelled. When new subscriber comes it starts from scratch.
I know it's a huge holywar in RxJs on this subjects.
There's a great article showing the differences: here
Could you comment on this please so we can brainstorm together.
I have a test that had quite a few publishes (7, to be exact) and I wanted to change the configuration to cancel at 1000
rather than 900
, but I was unable to create my own configuration because of the following build error:
'TestScheduler.Configuration' initializer is inaccessible due to 'internal' protection level
I believe this will require an explicit init()
marked with public
for TestScheduler.Configuration
.
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.