EP 106 · Combine Schedulers · Jun 15, 2020 ·Members

Video #106: Combine Schedulers: Erasing Time

smart_display

Loading stream…

Video #106: Combine Schedulers: Erasing Time

Episode: Video #106 Date: Jun 15, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep106-combine-schedulers-erasing-time

Episode thumbnail

Description

We refactor our application’s code so that we can run it in production with a live dispatch queue for the scheduler, while allowing us to run it in tests with a test scheduler. If we do this naively we will find that generics infect many parts of our code, but luckily we can employ the technique of type erasure to make things much nicer.

Video

Cloudflare Stream video ID: c6b07802cd6e4db2a80ebe70ceffae48 Local file: video_106_combine-schedulers-erasing-time.mp4 *(download with --video 106)*

References

Transcript

0:07

This was a lot of work to create this test scheduler, and honestly there may even be a few more subtle bugs lurking in the shadows. Scheduling is an incredibly complex topic, and we have really jumped into the deep end on this episode.

0:22

One way to take these ideas further would be to employ some formal methods for verifying scheduling behavior and to check that our test scheduler works correctly. We’ll leave that for another time though.

0:40

Now that we’ve got a test scheduler defined and implemented, let’s try to use it to make our view model more testable. Right now we are using DispatchQueue.main in a few spots in our view model, and as we have seen this is quite a complex dependency to have in our view model, and complicates testing. Using the test scheduler

1:01

If we wanted to use the test scheduler I guess we could try naively swapping out DispatchQueue.main for DisaptchQueue.testScheduler : .debounce(for: .milliseconds(300), scheduler: DispatchQueue.testScheduler)

1:08

However, this clearly isn’t right. We don’t want to use a test scheduler in production. We want to be able to use a live dispatch queue when running the app on a device, or even in a simulator or SwiftUI preview, and only in tests do we want to use the test scheduler.

1:21

So, sounds like we want to pass in the scheduler as a dependency to the view model, just as we have done when passing in the register and validate API endpoints. If we start out naively we might be tempted to do something like: init( register: @escaping (String, String) -> AnyPublisher<Bool, URLError>, validatePassword: @escaping (String) -> AnyPublisher<String, Never>, scheduler: DispatchQueue ) { }

1:39

But this isn’t right because we could only pass in dispatch queues, not test schedulers. The TestScheduler type is completely distinct from DispatchQueue . So instead we could genericize the initializer so that you can pass in a scheduler of any type, and then we will use that for our publisher: init<S: Scheduler>( register: @escaping (String, String) -> AnyPublisher<Bool, URLError>, validatePassword: @escaping (String) -> AnyPublisher<String, Never>, scheduler: S ) { … .debounce(for: .milliseconds(300), scheduler: scheduler) … validatePassword(password) .receive(on: scheduler).eraseToAnyPublisher() }

2:07

Then we can pass in a dispatch queue in both our SwiftUI preview and scene delegate: }, scheduler: DispatchQueue.main )

2:29

So the app should work just as it did before, but the real fun happens in tests.

2:35

Right now tests aren’t compiling because we aren’t passing in schedulers to any of the view models. We could try to naively just pass one along directly: let viewModel = RegisterViewModel( register: { _, _ in Just(true).setFailureType(to: URLError.self).eraseToAnyPublisher() }, validatePassword: { _ in Empty(completeImmediately: true).eraseToAnyPublisher() }, scheduler: DispatchQueue.testScheduler )

2:54

But we want to have access to this scheduler after creating the view model so that we can advance time forward. So we can pull out a scheduler and pass it along: let scheduler = DispatchQueue.testScheduler let viewModel = RegisterViewModel( register: { _, _ in Just(true).setFailureType(to: URLError.self).eraseToAnyPublisher() }, validatePassword: { _ in Empty(completeImmediately: true).eraseToAnyPublisher() }, scheduler: scheduler )

3:05

Even better, we can pull this scheduler out to be on the test case itself. let scheduler = DispatchQueue.testScheduler func testRegistrationSuccessful() {

3:13

And then we can pass this scheduler to every view model of the test case.

3:26

Now tests are compiling, and if we run tests we get a few errors. This is because currently when testing the password validation logic we are asking XCTWaiter to wait for .31 seconds, but that has no bearing on the test scheduler. We must tell the test scheduler directly advance, and then we should get tests passing: viewModel.password = "blob" // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.31) self.scheduler.advance(by: .milliseconds(300)) XCTAssertEqual( expectedOutput, [ "Enter a password between 5 and 20 characters", "Password is too short 👎" ] )

4:08

Now tests pass, and they are super quick. In fact, they take only a fraction of a second, whereas previously they took nearly a second. An over 99% improvement.

4:35

However, there’s something strange in our view model. In one spot we are using the scheduler that was injected into the initializer, but in the registerButtonTapped function we still using DispatchQueue.main . That’s why we still have those XCTWaiter calls in the register tests, and it’s why those tests are still passing. If the test scheduler was controlling those tests then they should fail since we are not advancing the scheduler.

4:57

Unfortunately, we don’t have access to the scheduler in the registerButtonTapped function. The scheduler is only accessible in the initializer. I suppose we could inject another scheduler into this function: func registerButtonTapped<S: Scheduler>(scheduler: S) { … .receive(on: scheduler) … }

5:16

But that is really strange.

5:18

Really what we’d like to do is hold onto the scheduler in the view model so that we could use it anywhere in the view model. Maybe in the future there are more methods that need to do work on a scheduler.

5:27

However, we can’t just add a new property like this: let scheduler: Scheduler Protocol ‘Scheduler’ can only be used as a generic constraint because it has Self or associated type requirements

5:38

Because the Scheduler protocol has associated types. The only way to capture this scheduler in full generality is to introduce a generic to the view model that represents what scheduler is used on the inside: class RegisterViewModel<S: Scheduler>: ObservableObject {

5:51

Then we can add our property: let scheduler: S

5:54

We can drop the generic from the initializer and store the scheduler, and use this scheduler in registerButtonTapped : init( register: @escaping (String, String) -> AnyPublisher<Bool, URLError>, validatePassword: @escaping (String) -> AnyPublisher<String, Never>, scheduler: S ) { self.register = register self.scheduler = scheduler … func registerButtonTapped() { …

6:12

There’s only one compiler error to fix, which is to specify the type of scheduler we are going to use in our view, which can be a live DispatchQueue for now: @ObservedObject var viewModel: RegisterViewModel<DispatchQueue>

6:29

Amazingly everything builds, even tests, thanks to the magic of type inference. However, tests do not pass, and that is a good thing because we are now properly using the test scheduler for registration, and so we have to fix those tests. // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) self.scheduler.advance() XCTAssertEqual(expectedOutput, [false, false]) // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.001) self.scheduler.advance() XCTAssertEqual(expectedOutput, [false, true]) // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.001) self.scheduler.advance() XCTAssertEqual(viewModel.isLoggedIn, true)

6:59

And now all of the tests pass, and super quickly! The problem with generics

7:07

So we are now reaping the rewards for all of our hard work. A test suite that used to be non-deterministic, fragile, took longer than necessary to run has become deterministic, precise and runs instantly.

7:22

We could pat ourselves on the back and take the rest of the day off, but there is still something still not quite right about what we have accomplished so far.

7:30

It seems very strange that we needed to introduce a generic to our view model in order to make it testable. This is loudly announcing to the world that not only does the view model need a scheduler to do its job, but even a specific type of scheduler. And this detail is going to spill out to any code that touches this view model and wants to be testable.

8:02

For example, the view that uses this view model is no longer testable because it forced the scheduler to be used to be a DispatchQueue : @ObservedObject var viewModel: RegisterViewModel<DispatchQueue>

8:12

If we wanted to write tests for this view, such as a snapshot test, we would want to be able to control the scheduler, which means we need to introduce another generic, this time for the view: struct ContentView<S: Scheduler>: View { @ObservedObject var viewModel: RegisterViewModel<S> … }

8:35

But then if another view wanted to embed this view inside it we would need to introduce yet another generic if we wanted to maintain testability for that view.

8:48

What we are seeing is that using generics like this will infect our types. We will be introducing generics, which is quite a heavy thing to do, to types and views that are simple and lightweight.

9:01

There’s something about what we are doing with the scheduler generic that makes it obvious that this is the wrong route, and it’s something we’ve mentioned a number of times on Point-Free. We first mentioned this 2 years ago when we first talked about the environment technique for controlling dependencies.

9:18

Often when we are looking to write code that supports two possible implementations, one for production and one for tests, we will turn to a protocol. We abstract away the interface in a protocol so that we can provide a live production conformance and a mock test conformance, and then we are free to swap in implementations whenever we want.

9:37

However, a protocol with just two conformances is not a strong form of abstraction. Protocols are meant for abstracting over many things at once, not just two things. For instance, the Scheduler protocol now has 5 conformances: dispatch queues, run loops, operation queues, immediate scheduler and the test scheduler. The Collection protocol in the standard library has tons of conformances, including arrays, dictionaries, sets, ranges, slices, collection differences, and more.

10:03

It is a substantial amount of code and boilerplate that needs to be maintained just so that you can swap between two implementations of an interface. As we’ve seen in our episodes on the environment technique and our episodes on protocol witnesses , there are often lower tech solutions to this problem.

10:21

We are seeing something similar now, but for generics. The only reason we introduced the scheduler generic was that we could either put in the live dispatch queue in production or could put in the test scheduler for tests. Generics are meant to abstract over all possible types in existence, and here we only mean this generic to be used for two specific types. This doesn’t sound like a great abstraction to have.

10:44

So let’s fix it.

10:53

Let takes another look at our view model: class RegisterViewModel<S: Scheduler>: ObservableObject { … let scheduler: S … }

11:04

It is not true that we want literally any scheduler to be plugged into this view model. As we can see in our app, we really only care about using DispatchQueue.main in the live app, and DispatchQueue.testScheduler in tests. So using generics to represent this is way too heavyweight.

11:20

What we really want to do is use a theoretical feature of Swift that would allow us to write this: let scheduler: any Scheduler where .SchedulerTimeType == DispatchQueue.SchedulerTimeType, .SchedulerOptions == DispatchQueue.SchedulerOptions

12:07

This is a bit of a mouthful, but it’s allowing us to still do some form of generic programming without introducing a generic. We are saying that we are fine with any scheduler being passed in, as long as its associated types are of the same type as DispatchQueue . That means in production can use the live main DispatchQueue , and in tests we can use the test scheduler.

12:32

Well, unfortunately Swift does not have this feature today, and so we have to turn to a more manual, ad hoc solution to this problem. It’s called type erasure, and it’s not something we have spent a ton of time talking about on Point-Free.

12:47

Type erasure is the process of removing some type information and concealing the true type of a value from us. For example, this line is saying that scheduler can be any value conforming to Scheduler , but we do not know its concrete type statically: let scheduler: any Scheduler where .SchedulerTimeType == DispatchQueue.SchedulerTimeType, .SchedulerOptions == DispatchQueue.SchedulerOptions

13:21

This is a deficiency of Swift’s type system that will hopefully someday be fixed. Even the standard library and some of Apple’s frameworks come across this problem. For example, it is not possible to have a variable of any collection whose element is an integer: let xs: any Collection where .Element == Int

13:45

If this was possible then we could store any type of collection in here, such as arrays, array slices, ranges, sets and more. We would have statically erased the true underlying collection, and only preserved its collection-ness. let xs: any Collection where .Element == Int xs = [1, 2, 3] xs = Set([1, 2, 3]) …

14:04

However, this isn’t possible, so the standard library ships with an AnyCollection type to emulate this type feature: let xs: AnyCollection<Int>

14:21

Now we can start any type of collection in here: let xs = AnyCollection(Set([1, 2, 3])) let ys = AnyCollection([1, 2, 3]) let zs = AnyCollection(0...2)

14:50

And even more interesting, we can make an array of these collections even though the underlying collection types are not all the same: [xs, ys, zs]

15:09

This is also showing how this type erasing behavior allows us to create collections that are seemingly heterogeneous, that is, all the elements are not of the exact same type.

15:42

And Swift ships a whole bunch of these “any” wrappers too, including: // AnyHashable // AnyIterator // AnySequence // AnyCollection // AnySubscriber // AnyCancellable // AnyPublisher // AnyView just to name a few.

16:03

These types only exist to work around this deficiency in the Swift type system. Someday Swift may be capable of hiding away these details so that you can simply do: let xs: any Collection where .Element == Int

16:19

But until then we have to write these wrappers ourselves, and they are called “type erasing wrappers” because they hide away the true type of what is conforming to the protocol. We need one of these for the Scheduler protocol, and strangely the Combine framework does not provide it (even though it does provide AnySubscriber , AnyPublisher and AnyCancellable ).

16:40

If you’ve never had to create one of these type erasing wrappers before count yourself lucky. It’s definitely not fun code to write, and the compiler should be able to do it for us, but we’ll take it step-by-step so that you can understand how it goes.

16:54

Creating the AnyScheduler

16:54

To accomplish this, we will introduce one of those type erasing wrappers and call it AnyScheduler . // let scheduler: any Scheduler where // .SchedulerTimeType == DispatchQueue.SchedulerTimeType, // .SchedulerOptions == DispatchQueue.SchedulerOptions let scheduler = AnyScheduler< DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>

16:54

We start by creating a concrete type type that has a generic for each associated type of the Scheduler protocol with the same constraints, and this new type will conform to the Scheduler protocol: struct AnyScheduler<SchedulerTimeType, SchedulerOptions>: Scheduler where SchedulerTimeType: Strideable, SchedulerTimeType.Stride: SchedulerTimeIntervalConvertible { }

18:16

The idea for this type is that it will be initialized with a concrete scheduler, but after initialization all of the type information for the scheduler will be lost, and the only thing that will be exposed will be the scheduler protocol interface.

18:32

To accomplish this we will have an initializer on this type that takes any type of scheduler, but we won’t actually hold onto that scheduler: init<S: Scheduler>(_ scheduler: S) where S.SchedulerTimeType == SchedulerTimeType, S.SchedulerOptions == SchedulerOptions { }

18:59

We want to capture this scheduler’s functionality in closures here, and then forget that we ever even had a concrete scheduler. This is how we successfully erase the type so that it is impossible to recover.

19:10

In particular, the functionality we want to capture is all of the protocol requirements of the Scheduler protocol. Right now we have a compiler error telling us that we are missing those requirements, so let’s have the compiler fill in some stubs for us: var now: SchedulerTimeType var minimumTolerance: SchedulerTimeType.Stride func schedule( options: SchedulerOptions?, _ action: @escaping () -> Void ) { <#code#> } func schedule( after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) { <#code#> } func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable { <#code#> }

19:49

These 5 requirements must be implemented in order for the AnyScheduler to be considered a scheduler.

19:55

We want to implement these requirements by secretly calling down to the scheduler that was passed into the initializer, but how can we do that? Our goal is to completely erase the scheduler and forget that it ever existed.

20:08

Well, we can add closure properties to AnyScheduler that match the signatures of these requirements, and then in the initializer we define those closures by capturing the scheduler. Once that is done we can invoke those closures from these methods and properties, all without ever having access to the scheduler itself.

20:30

Let’s get started by fulfilling the protocol’s the now requirement: private let _now: () -> SchedulerTimeType init<S: Scheduler>(_ scheduler: S) where S.SchedulerTimeType == SchedulerTimeType, S.SchedulerOptions == SchedulerOptions { self._now = { scheduler.now } } var now: SchedulerTimeType { self._now() }

21:13

We can do the same for minimumTolerance : private let _minimumTolerance: () -> SchedulerTimeType.Stride init<S: Scheduler>(_ scheduler: S) where S.SchedulerTimeType == SchedulerTimeType, S.SchedulerOptions == SchedulerOptions { … self._minimumTolerance = { scheduler.minimumTolerance } } … var minimumTolerance: SchedulerTimeType { self._minimumTolerance() }

31:38

And finally, we can capture the three schedule methods. private let _schedule: (SchedulerOptions?, @escaping () -> Void) -> Void private let _scheduleAfterDelay: ( SchedulerTimeType, SchedulerTimeType.Stride, SchedulerOptions?, @escaping () -> Void ) -> Void private let _scheduleWithInterval: ( SchedulerTimeType, SchedulerTimeType.Stride, SchedulerTimeType.Stride, SchedulerOptions?, @escaping () -> Void ) -> Cancellable init<S: Scheduler>(_ scheduler: S) where S.SchedulerTimeType == SchedulerTimeType, S.SchedulerOptions == SchedulerOptions { … self._schedule = { scheduler.schedule(options: $0, $1) } self._scheduleAfterDelay = { scheduler.schedule(after: $0, tolerance: $1, options: $2, $3) } self._scheduleWithInterval = { scheduler.schedule( after: $0, interval: $1, tolerance: $2, options: $3, $4 ) } } func schedule( options: SchedulerOptions?, _ action: @escaping () -> Void ) { self._schedule(options, action) } func schedule( after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) { self._scheduleAfterDelay(date, tolerance, options, action) } func schedule( after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) -> Cancellable { self._scheduleWithInterval( date, interval, tolerance, options, action ) }

23:58

This may seem messy, and we totally agree, it is messy. All of this could could be written for us by the compiler, and hopefully someday it will. But, until then, we can do this little bit of upfront work and it will pay dividends for us very soon. Using the AnyScheduler

24:23

The AnyScheduler is compiling and so is the property we defined in our view model, but we still have a few more compiler errors to deal with.

24:39

Instead of passing in a generic S to our initializer, we should pass along an AnyScheduler . init( register: @escaping (String, String) -> AnyPublisher<Bool, URLError>, validatePassword: @escaping (String) -> AnyPublisher<String, Never>, scheduler: AnyScheduler< DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions > ) {

24:49

And then in our view we can drop the scheduler generic, which is now really cool because we are no longer publicly exposing what scheduler we are using on the inside. That information just isn’t important: struct ContentView: View { @ObservedObject var viewModel: RegisterViewModel … }

25:23

Next we have to fix the places we create this view, such as the SwiftUI previews and scene delegate. We can simply wrap the scheduler in AnyScheduler : scheduler: AnyScheduler(DispatchQueue.main) Ergonomic interlude

25:43

Here we are seeing a very good application of type erasure. We had a generic infecting all of our types: our view models, our views, and anything that wanted to be testable would need to introduce this generic. But we only cared about two types that we wanted to plug in, one for our live application and one for tests, so it felt wrong and heavyweight. But we were able to solve that problem using type erasure.

26:12

We’ll have more to say about type erasure in the future.

26:20

Things are looking good with AnyScheduler , but let’s smooth out the ergonomics.

26:33

Let’s look at how we refer to these AnyScheduler s in practice: let scheduler: AnyScheduler< DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions > … init( … scheduler: AnyScheduler< DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions > ) { …

26:37

This is pretty verbose, so we can also introduce a type alias helper that figures out the generics under the hood from a concrete scheduler type: typealias AnySchedulerOf<S: Scheduler> = AnyScheduler< S.SchedulerTimeType, S.SchedulerOptions >

27:12

Then we can simply say: let scheduler: AnySchedulerOf<DispatchQueue> … init( … scheduler: AnySchedulerOf<DispatchQueue> ) { …

27:32

But we could also take some inspiration from Combine’s API design by introducing an eraseToAnyScheduler to mimic the eraseToAnyPublisher method: extension Scheduler { func eraseToAnyScheduler() -> AnyScheduler< SchedulerTimeType, SchedulerOptions > { AnyScheduler(self) } }

28:19

And now we can do this in both our SwiftUI preview and scene delegate: scheduler: DispatchQueue.main.eraseToAnyScheduler()

28:40

And then finally we can fix our tests by simply erasing the test scheduler: scheduler: scheduler.eraseToAnyScheduler()

29:09

And now tests have passed, but we’ve accomplished something very nice. We are allowing our view model to be quite generic with respect to what scheduler it works with. In particular we can use a DispatchQueue in production and TestScheduler in tests, and really any scheduler that uses DispatchQueue ’s associated types. But we are able to accomplish this without letting any of the static information of the scheduler to leak out into the public or infect the types that need to interact with the view model. This is a very powerful concept, and is deeply related to a topic known as “existential types”, which we hope to cover some day in the future. What’s the point?

29:40

So we have accomplished quite a bit in the past few episodes.

29:44

First we explored what it takes to build a view model that powers a vanilla SwiftUI feature, no fancy Composable Architecture or anything. We saw that Combine allows us to easily layer on quite complex functionality, but the bigger the machinery you reach for the harder it is to test. In particular, we needed to do explicit waits for very small amounts of time to get past thread hops when using the .receive(on:) operator, and we needed to wait for longer periods of time in order to test code that uses debouncing.

30:13

Then we explored ways to make testing these operators a bit easier. In particular, we showed that we could cook up a whole new scheduler from scratch that allows us to control the flow of time. This mean we could test publishers that have lots of asynchrony and delays in them instantly by telling the scheduler to advance time by a precise amount. This not only made our test suites run faster but also made it more stable and removed hacks to guess how much time we needed to wait for our code to execute.

30:44

And then finally we showed that although the test scheduler allowed us to test our view model more easily, adopting it naively caused our code to be infected with generics. So, we just needed to employ a little technique known as type erasure to allow that code to continue being as generic as we needed while also hiding some of the static information from the outside.

31:05

And so we think we have provided a pretty cohesive package of how one tests and controls publishers in the Combine framework.

31:12

However, on Point-Free we like to end each series of episodes by asking the question: what’s the point? This is our opportunity to make sure that we bring things down the earth and are giving our viewers something useful that they can use in their everyday code.

31:25

So, is this useful?

31:26

We say yes! Even if you don’t think you write a lot of highly asynchronous code, like things dealing with debouncing, it can be surprising how asynchrony can sneak into your code. To see this let’s explore a complex helper that we wrote and see what it would take to test it.

31:48

Consider a function with the following signature: func race<Output, Failure: Error>( cached: Future<Output, Failure>, fresh: Future<Output, Failure> ) -> AnyPublisher<Output, Failure> { … }

32:34

It’s passed two units of work, one Future represents some value that can be retried from the cache and the other Future represents a fresh value that will be computed. We would like this function to have the following behavior:

32:39

If cached emits first we will also emit that value, but we will also wait for the fresh value to also emit.

32:49

If the fresh publisher emits first it will stop the cached publisher from emitting and only emit the value from fresh .

33:00

This is essentially saying that we always want to get a fresh value, but if the cached value can return quicker then we will take it too.

33:14

The implementation of this function is surprisingly tricky. It took us a little while to get it right, and it can probably be simplified, but this is what we came up with: func race<Output, Failure: Error>( cached: Future<Output, Failure>, fresh: Future<Output, Failure> ) -> AnyPublisher<Output, Failure> { Publishers.Merge( cached.map { (model: $0, isCached: true) }, fresh.map { (model: $0, isCached: false) } ) .scan((nil, nil)) { accum, output in (accum.1, output) } .prefix(while: { lhs, rhs in !(rhs?.isCached ?? true) || (lhs?.isCached ?? true) }) .compactMap(\.1?.model) .eraseToAnyPublisher() }

33:26

The details of this implementation are not important at all. All that is important is that we want to write a test for this logic, and we can use test schedulers to make it incredibly easy.

33:45

For example, one thing we could do is race two futures that use a test scheduler internally to determine when they emit. We could start by having a test that makes sure the cached value is allowed to emit if it happens before the fresh value: func testCacheEmitsFirst() { var output: [Int] = [] race( cached: Future<Int, Never> { callback in scheduler.schedule(after: scheduler.now.advanced(by: 1)) { callback(.success(2)) } }, fresh: Future<Int, Never> { callback in scheduler.schedule(after: scheduler.now.advanced(by: 2)) { callback(.success(42)) } } ) .sink { output.append($0) } .store(in: &self.cancellables) XCTAssertEqual(output, []) scheduler.advance(by: 2) XCTAssertEqual(output, [2, 42]) }

35:31

Then we could test what happens when the fresh emits first. In particular, we expect that only the fresh emission to happen: func testFreshEmitsFirst() { var output: [Int] = [] race( cached: Future<Int, Never> { callback in scheduler.schedule(after: scheduler.now.advanced(by: 2)) { callback(.success(2)) } }, fresh: Future<Int, Never> { callback in scheduler.schedule(after: scheduler.now.advanced(by: 1)) { callback(.success(42)) } } ) .sink { output.append($0) } .store(in: &self.cancellables) XCTAssertEqual(output, []) scheduler.advance(by: 2) XCTAssertEqual(output, [42]) }

35:56

And the publisher we are testing here represents a pretty common task that one needs to do in applications. We often need to be able to run lots of asynchronous tasks and gather their values together, sometimes allow the emission of one task cancel another. There’s no fancy debouncing or delays or timers insight. It’s just a few simple tasks. And the test scheduler allows us test this code with confidence.

36:23

So this is why we think the test scheduler is an important tool to have. Basically any Combine code you write that has even a modicum of asynchrony in it, such as even just doing .receive(on: DispatchQueue.main) , will get benefits from using the Scheduler protocol and TestScheduler . We honestly aren’t sure why Apple did not ship Combine with the AnyScheduler or TestScheduler , but hopefully WWDC this year will have some surprises!

36:51

Until next time. References combine-schedulers Brandon Williams & Stephen Celis • Jun 14, 2020 An open source library that provides schedulers for making Combine more testable and more versatile. http://github.com/pointfreeco/combine-schedulers Downloads Sample code 0106-combine-schedulers-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .