Is there an easy way to inject a Combine Scheduler?

With other reactive frameworks, a common technique is injecting schedulers into any code that uses any kind of thread-hopping operator. This is helpful for testing, where you can inject a TestScheduler to synchronously execute a simulation with virtual time.

In Combine, Scheduler has associated types: SchedulerTimeType and SchedulerOptions. This makes the prospect of injecting a scheduler extremely daunting; your types would need to be made generic over both these associated types if you wanted to, for example, inject DispatchQueue.main for your app and a TestScheduler for your unit tests.

The problem is that the Scheduler protocol does more than I need; most of my usage of schedulers only involves calling the receive(on:) operator where I don't care at all about the time type or scheduling options, and it would make things much easier to have a more granular protocol to represent that.

One possibility I considered was creating my own protocol with a subset of Scheduler's functionality: the ability to execute code immediately.

protocol ExecutionContext {
    func execute(_ action: @escaping () -> Void)
}

extension Scheduler {
    func execute(_ action: @escaping () -> Void) {
        schedule(action)
    }
}

extension DispatchQueue: ExecutionContext { }
extension RunLoop: ExecutionContext { }
extension TestScheduler: ExecutionContext

Then I could re-implement operators like receive(on:) to use an execution context instead of a scheduler with all of its associated types. The major downside, of course, is that I'd have these non-standard operator scattered all over my codebase, not to mention my operator implementations are more likely to be buggy than Combine's official operators.

So this is kind of a desperate shot in the dark, but is there some technique or advanced form of type-erasure that would allow me to have the best of both worlds? Easy scheduler injection and standard Combine operators?

1 Like

You can use ImmediateScheduler for synchronous execution.

Thanks @ddddxxx! My question is more about how I could write code that uses DispatchQueue.main in my app but ImmediateScheduler for unit testing. Because each of them has different associated types, it's not as simple as just passing it in as a parameter.

You should just be able to copy the subscribe(on:) signature, right?

func subscribe<S>(on scheduler: S, options: S.SchedulerOptions? = nil) -> Publishers.SubscribeOn<AnyPublisher<Output, Failure>, S> where S : Scheduler

I do question the validity of tests on a stream that's async in your app but synchronous in your tests, as you're not testing the same thing in that case.

CXTest is a test infrastructure for Combine. It provides VirtualTimeScheduler that allows you to manipulate time synchronously. It also used by CXExtensions package.

@jjoelson Do you think a type erased AnyScheduler would help? I’d like to implement it in CXExtensions.

2 Likes

@Jon_shier I'd like to avoid having to make all my classes generic to support this as that would throw a major wrench into my dependency injection system, but you're correct that this only requires a single generic parameter; I'm not sure why I thought I'd have to make it generic over each associated type :sweat_smile:.

I do question the validity of tests on a stream that's async in your app but synchronous in your tests, as you're not testing the same thing in that case.

I would not use ImmediateScheduler for tests, I would use a special test scheduler (such as what @ddddxxx linked to or EntwineTest) where the order in which scheduled blocks are executed is carefully controlled to simulate asynchronous scenarios. I don't think it makes the tests invalid; it's a common technique in the RxSwift world at any rate (here’s a good write-up from Shai Mishali).

@jjoelson Do you think a type erased AnyScheduler would help? I’d like to implement it in CXExtensions.

If it's possible to create a non-generic AnyScheduler that could be passed to the receive(on:) operator, then that would certainly work for my use case. Do you reckon that's possible?

Done AnyScheduler (It's way more complicated than I expected).

1 Like

Wow, that looks insanely complicated, but it works perfectly with receive(on:). Thanks!

I have a feeling it might run into some trouble with time-shifting operators, but I will continue experimenting.

AnyScheduler in Point Free's combine-schedulers package works too.

Terms of Service

Privacy Policy

Cookie Policy