Confused about behaviour of switchToLatest in Combine

@Tony_Parker, did you have a chance to look into it?

Yes, but nothing to report here yet.

1 Like

@Tony_Parker,
There is one more issue with it, probably root cause is the same, but let me show to be sure that it will be also taken in account.
This test will fail by timeout as subscriber not receive completion

func testSwitchToLatest() {
    let canceled = expectation(description: "Should be canceled")
    let finished = expectation(description: "Should be Finished")

    var valuesCount = 0
    let subject = PassthroughSubject<Void, Never>()
    let delay: TimeInterval = 1
    let scheduler = DispatchQueue(label: "testSwitchToLatest")

    var cancellable: AnyCancellable? = subject.map { _ in
        Just(()).delay(
            for: .seconds(delay),
            scheduler: scheduler
        ).handleEvents(
            receiveSubscription: { _ in
                print("Started")
            },
            receiveCancel: {
                print("Canceled")
                canceled.fulfill()
            }
        )
    }.switchToLatest().sink(
        receiveCompletion: { _ in
            finished.fulfill()
        },
        receiveValue: { _ in
            valuesCount += 1
        }
    )

    subject.send(())
    subject.send(())

    // If deadline > .now() + delay than you will receive completion otherwise not
    let deadline: DispatchTime = .now() + delay/2
    scheduler.asyncAfter(deadline: deadline) {
        subject.send(completion: .finished)
    }

    wait(for: [canceled, finished], timeout: 5)
    cancellable?.cancel()
    cancellable = nil

    XCTAssert(valuesCount == 1)
}

Hi @andrei-kuzma,

I agree it is the same bug.

That was a bug, and it's fixed in Xcode 11.4 (currently in beta).

2 Likes

Can confirm this seems to be fixed in Xcode 11.4 beta 3.

2 Likes

@mattneub & @freak4pc - Thanks for verifying the fix, and thank you @mikevelu for the bug report!

1 Like

Hey @Tony_Parker, sorry to be the bearer of bad news, but it seems this has regressed back in the final release of 11.4.

Here's a test showcasing this:

class FakeSuite: XCTestCase {
    var subscription: AnyCancellable!
    func test() {
        let expect = expectation(description: "tester")
        var outerCompleted = false
        subscription = Just("trigger")
            .map { _ -> AnyPublisher<Void, Never> in
                Timer
                    .publish(every: 0.1, on: RunLoop.current, in: .common)
                    .autoconnect()
                    .map { _ in () }
                    .prefix(8)
                    .handleEvents(receiveOutput: { _ in print("inner value") },
                                receiveCompletion: { print("inner completed: \($0)") })
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .sink(
                receiveCompletion: {
                    print("completed \($0)")
                    outerCompleted = true
                    expect.fulfill()
                },
                receiveValue: { _ in print("value") }
            )
        wait(for: [expect], timeout: 2)
        XCTAssertTrue(outerCompleted)
    }
}

On Xcode 11.4 beta 3 this outputs the following and passes:

inner value
value
inner value
value
inner value
value
inner value
value
inner value
value
inner value
value
inner value
value
inner value
value
inner completed: finished
completed finished

On Xcode 11.4 Final the test fails and I'm only seeing this output:

inner value
inner value
inner value
inner value
inner value
inner value
inner value
inner value
inner completed: finished

I also see a

Asynchronous wait failed: Exceeded timeout of 2 seconds, with unfulfilled expectations: "tester".

This actually seems to be in worse shape?
The inner publisher completes and emits, but I'm not seeing its events relayed outside at all.

By the way adding a .print("stl") after the switchToLatest yields only

stl: receive subscription: (SwitchToLatest)
stl: request unlimited

Hi @freak4pc. Given your level of experience, do you have any advice about dealing with the various behaviors of Combine across system versions? For example, did you have to rewrite basic publishers as replacements for the lacking of flaky built-in ones?

It's not that I want to stress out that the framework is (obviously) still lacking a solid test suite. I'm just wondering what I'm supposed to do when I really have to ship an app that can use Combine, so that... I can plan the amount of required work.

Edit: @Tony_Parker, your advice is welcome too, actually. Sorry for not mentioning you.

Hey Gwendel!

I didn't rewrite any of the basic publishers, but I have some custom publishers of things I'm missing for what I'd consider "production use" (withLatestFrom, materialize, dematerialize, and more). You can see those here: https://github.com/CombineCommunity/CombineExt

IRT to testing it seems there isn't a proper Test Scheduler like we have in RxSwift, yet. You can either use something like Entwine's TestScheduler or use sink and some XCTestExpectation(s). See the test suite of CombineExt above to see some the latter option.

All right. I guess the Combine warts don't hit that often, then. Thank you :ok_hand:

I definitely have an eye on this one ;-)

1 Like

I think it's mostly stable :) Especially given how young it is.

Totally. I guess you remember the early days of RxSwift, and... it must have been sweaty ;-). I came to it long after it was already stabilized: I was lucky ;-)

1 Like

Do you know about https://github.com/groue/CombineExpectations?

It does not help testing schedulers. But it helps testing behaviors of black-box publishers:

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!"])
    }
}

It's like RxBlocking, but without any RunLoop voodoo: it just uses XCTestExpectation.

2 Likes

Cool! Looks like a neat wrapper around XCTestExpectation :))

1 Like

What version of the OS are you on? Combine ships as part of iOS/macOS, so it's that version which matters, not Xcode.

@Tony_Parker Ah, that's interesting, didn't know this.

I'm running macOS Catalina 10.15.3. Actually when running the tests in an iOS 13.4 simulator, it passes. When running the tests on macOS (e.g. "My Mac"), it fails with the output I showed you above. Very stranage.

Thanks!

Running an iOS app in the iOS 13.4 simulator makes it run with the iOS 13.4 version of Combine.

Running a macOS app on macOS 10.15.3 makes it run with the macOS 10.15.3 version of Combine. But you need macOS 10.15.4 to get the same common frameworks as iOS 13.4.