Video #140: Better Test Dependencies: Immediacy
Episode: Video #140 Date: Mar 29, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep140-better-test-dependencies-immediacy

Description
A major source of complexity in our applications is asynchrony. It is a side effect that is easy to overlook and can make testing more difficult and less reliable. We will explore the problem and come to a solution using Combine schedulers.
Video
Cloudflare Stream video ID: 36df66a96c6f7c1a60953684aa3483ae Local file: video_140_better-test-dependencies-immediacy.mp4 *(download with --video 140)*
References
- Discussions
- MetaWeather API service
- the package
- isowords
- Composable Architecture
- 0140-better-test-dependencies-pt3
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So this is pretty amazing. We are now getting a ton of insight into our code base by embracing exhaustive dependencies, and in particular invoking XCTFail immediately for any dependencies that we do not expect to be called. This allows us to be instantly notified when one of our features starts accessing a dependency we don’t expect, and on the flip side allows us to introduce new dependencies to our feature and be instantly notified of which tests need to be updated.
— 0:51
But there’s still more to discover. Failing dependencies greatly improved the developer experience when writing tests, but there is still room for more improvement. When we write tests that deal with time, such as delaying or debouncing, we like to use a test scheduler because it allows us to deterministically control the flow of time. We even have a todo test that specifically asserts that when you complete a todo, wait half a second, then complete another todo, and then wait a full second, that the todos were not sorted until the full one and a half seconds passed. It could actually capture that intermediate moment where the second todo’s completion cancelled the sorting effect. And that’s incredibly powerful.
— 1:36
However, sometimes we deal with schedulers that do not involve the passage of time. They are just used to execute on specific queues, such as when you use the .subscribe(on:) or .receive(on:) operator. If we use the test scheduler for these situations we have to litter our tests with scheduler.advance() calls in order to push them a tick forward and execute their work. Sometimes you do really want that, like if you want to test some synchronous effects that run before an asynchronous effect. However, most of the times it’s an unnecessary annoyance, and we can definitely improve it.
— 2:09
Even better, by addressing this test annoyance we’ll actually unlock something really cool for SwiftUI previews. We’ll show how we can exercise more of our feature’s logic using static previews when typically you would have to resort to running the live preview.
— 2:24
Let’s start by demonstrating the problem that test schedulers can cause. We are going to resurrect the project we built for our “ Designing Dependencies ” series of episodes. In those episodes we built a moderately complex application that made use of an API client, a location manager, and a network monitor in order to implement a simple weather app. Let’s recap: Designing dependencies: recap and problem
— 2:49
Here we are in the code base of the application we built earlier. We can run the app in the simulator and we’ll see that we can:
— 2:57
Tap the location button and it detects that we have not yet given authorization for our location and so it asks for it.
— 3:05
If we authorize it will fetch our location, fill it in at the top, and then make an API request for weather in our area and show the results in a list.
— 3:14
This application also listens for changes in network connectivity, and if you lose internet it will show a little banner letting you know you are offline.
— 3:22
That’s quite a bit for a demo application, but we showed that if you put in a little upfront work to properly design and control your dependencies there are huge payoffs:
— 3:31
First, properly designing our dependencies allows us to separate the stuff that takes a long time to build from the stuff that builds quickly. We created lightweight interfaces to the underlying dependency, and put them in a module separate from the real dependency. This vastly improved our build times, and the only time we had to build the actual real, heavy dependency was when running the application on our device or simulator.
— 3:53
We can even see this easily right here. If we build the WeatherFeature module, which holds all the core logic of the screen we just demo’d, we will see it builds nearly instantly, even after a clean. On the other hand, if we build the DesigningDependencies target, which is the actual app target running on the device or simulator, we see it takes a long time. And that’s because it has to build a live dependency, which wasn’t needed when just building the feature in isolation.
— 4:35
So that’s pretty cool, but then even better, by properly designing our dependencies we also made our SwiftUI previews much more useful. Currently there are many Apple APIs that are not usable in previews. Things like location managers, motion managers, speech recognizers, network monitors, and more. The moment you let those APIs get into your business logic you lose a lot of the power of SwiftUI previews.
— 4:56
However, when we wrapped those dependencies with our own interfaces and implementations we instantly gained the ability to load up a SwiftUI preview in whatever kind of environment we want. We could run the preview in the most ideal situation: authorization location access, satisfied network monitor and an API client that never fails: viewModel: AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: .happyPath )
— 5:31
Or we could simulate what happens when the network connection is flakey, which for the purposes of this dependency means it goes back and forth between satisfied and unsatisfied: viewModel: AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .flakey, weatherClient: .happyPath )
— 5:45
Now we can see the banner flashing on and off.
— 5:55
We can even simulate the situation where we do not have authorization for location access, but the moment we ask for it is granted so that we can further see that location results are loaded: viewModel: AppViewModel( locationClient: .notDetermined, pathMonitorClient: .satisfied, weatherClient: .happyPath )
— 6:29
And now we get a somewhat functional application right in the preview, despite our use of a bunch of APIs that just don’t work in previews otherwise.
— 6:45
So that right there is already pretty impressive, but it got even better. We then showed that all these cool ways of controlling and simulating the environment the feature operates in for SwiftUI previews has direct applications to how we write tests for our features. We wrote a full test suite that exercises most parts of this application. Everything from the happy path of all dependencies working as expected, to the unhappy path of being denied location permissions or our API requests failing. These tests were straightforward to write, ran instantly without needing to use XCTExpectation s to wait for time to pass, and were fully stable and deterministic.
— 7:36
However, there was one dependency that we did not control in these episodes. We did this originally to keep things simple, and focus on the important topic of designing dependencies, but now it’s a pretty glaring omission, especially in light of all our recent work on schedulers.
— 7:50
If we go to the live implementation of our weather API client we will see this: extension WeatherClient { public static let live = Self( weather: { id in URLSession.shared.dataTaskPublisher( for: URL(string: "https://www.metaweather.com/api/location/\(id)")! ) .map { data, _ in data } .decode(type: WeatherResponse.self, decoder: weatherJsonDecoder) .receive(on: DispatchQueue.main) .eraseToAnyPublisher() }, searchLocations: { coordinate in URLSession.shared.dataTaskPublisher( for: URL( string: "https://www.metaweather.com/api/location/search?lattlong=\(coordinate.latitude),\(coordinate.longitude)" )! ) .map { data, _ in data } .decode(type: [Location].self, decoder: weatherJsonDecoder) .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } ) }
— 7:54
We are using URLSession to fire off network requests to the MetaWeather API service , but because that publisher delivers its results on a background queue we further tack on a .receive(on: DispatchQueue.main) to get it back on the main thread.
— 8:10
This was done to simplify how we work with this API in our view model, but it’s not how we suggest handling it in a real life application. Dependencies should perform as little logic as possible in their implementations. They should just do the bare minimum of work to get some raw results, and then pass that along to the client. And the business logic should make as few assumptions as possible about what the dependency is doing under the hood, and so should probably take responsibility for threading itself.
— 8:35
So, let’s remove these .receive(on:) ’s in the dependency: // .receive(on: DispatchQueue.main)
— 8:38
And move them to the business logic, in particular the view model: self.weatherRequestCancellable = self.weatherClient .weather(location.woeid) .receive(on: DispatchQueue.main) … self.searchLocationsCancellable = self.weatherClient .searchLocations(location.coordinate) .receive(on: DispatchQueue.main) …
— 8:59
The SwiftUI previews act just as they did before, and if we ran in the simulator that too would work the same.
— 9:01
However, when we run tests we now get lots of failures. For example, this one: func testBasics() { let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: WeatherClient( weather: { _ in .init(.moderateWeather) }, searchLocations: { _ in .init([.brooklyn]) } ) ) XCTAssertEqual(viewModel.currentLocation, .brooklyn) XCTAssertEqual(viewModel.isConnected, true) XCTAssertEqual( viewModel.weatherResults, WeatherResponse.moderateWeather.consolidatedWeather ) }
— 9:14
This is failing because asynchronously dispatching work back to the main queue takes a full tick of the run loop to pass before it gets executed. So, if we force the test suite to wait for a really minuscule amount of time to pass and then make our assertions: func testBasics() { let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: WeatherClient( weather: { _ in .init(.moderateWeather) }, searchLocations: { _ in .init([.brooklyn]) } ) ) _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) XCTAssertEqual(viewModel.currentLocation, .brooklyn) XCTAssertEqual(viewModel.isConnected, true) XCTAssertEqual(viewModel.weatherResults, WeatherResponse.moderateWeather.consolidatedWeather) }
— 9:48
Now we clearly don’t want to do this. Using expectations like this is really unstable. First, waiting 0.01 seconds is working now, but if the computer is running really slow for some reason then it may need even more time and you will get intermittent test failures that have nothing to do with how your logic is executing. So, that may lead you to increase the wait time in order to minimize flakiness, but then you may accidentally make your test suite run much longer than expected. Controlling our dispatches to main
— 10:17
So we don’t want to go this route. The crux of the problem is that we have introduced an unexpected side effect into our code by sprinkling dispatch queues in the view model. Dispatch queues, run loops and operation queues are all side effects because they use outside mechanisms to schedule and run work.
— 10:36
Luckily there’s a way to take back control of our code, and it’s something we covered on Point-Free awhile ago when we did a deep dive into Combine schedulers. We showed that we can build a test scheduler, which is a special conformance to the Scheduler protocol that allows you to explicitly control how time flows through your application. We used it to great effect, allowing us to clean up a flakey test suite and reduce the test suite run time by more than 90%.
— 11:03
So let’s give that a shot. Let’s add a dependency that gives us access to all that cool test scheduler stuff. We have a library called combine-schedulers and provides a lot great functionality that we hope someday Apple will include with Combine:
— 11:25
We can first add the package as a project dependency.
— 11:49
With that added we now want to pass the scheduler into the view model rather than baking it right into the internals, just as we do with all the other dependencies, such as the weather client, network monitor client and location client.
— 12:11
But how? We can’t just add an instance variable that represents the main queue like this: let mainQueue: Scheduler Protocol ‘Scheduler’ can only be used as a generic constraint because it has Self or associated type requirements
— 12:20
This is because the Scheduler protocol has associated types, and we cannot use such protocols as a bare type like this. In our previous episodes on Combine schedulers we showed that technically one could work around this by introducing a generic to the view model: public class AppViewModel<S: Scheduler>: ObservableObject { … let mainQueue: S … }
— 12:47
This certainly would allow us to plug any scheduler we want into this view model. We could use a live dispatch queue for when running in SwiftUI previews and a test scheduler when running in tests. However, doing this is hugely problematic. This generic is going to infect our code. Any piece of code that wants to touch this view model will also need to have a generic if it wants a shot at being testable.
— 13:12
For example, if we want our view to someday be testable, say via snapshot testing , then we’d need to make it generic over the scheduler so that it could be controlled with a test scheduler: public struct ContentView<S: Scheduler>: View { @ObservedObject var viewModel: AppViewModel<S> … }
— 13:47
Further, any view that shows this view will also pick up a generic. This will keep going, and next thing you know we’ve introduced a generic in half our code base just so that we could use a live dispatch queue for production and a test scheduler in tests. That is way too heavyweight, and luckily there’s a better way.
— 14:08
The solution is to make up for Swift’s lack of existential types by using what is known as a type erasing wrapper. These are topics we have talked about a number of times on Point-Free, and so we aren’t going to go in depth now, but suffice it to say that existential types allow your code to be generic without literally introducing a generic in your code, which is kinda what we want here. We highly recommend you watch some of those older episodes, which we have links to at the bottom of the episode page .
— 14:36
So, let’s just jump straight to the solution. We don’t want to introduce a generic, so let’s get rid of that: public class AppViewModel: ObservableObject { … }
— 14:52
And rather than saying that our mainQueue instance variable is literally any type of scheduler, we will say that it is any kind of scheduler whose associated types match that of DispatchQueue . We do this using the AnyScheduler type that comes with the combine-schedulers library: let mainQueue: AnySchedulerOf<DispatchQueue>
— 15:20
And we need to make sure to pass this new dependency into the view model when creating it: public init( … mainQueue: AnySchedulerOf<DispatchQueue> ) { … self.mainQueue = mainQueue }
— 15:30
And now we can start using this controlled dependency instead of reaching out to a live dispatch queue: self.searchLocationsCancellable = self.weatherClient .searchLocations(location.coordinate) .receive(on: self.mainQueue) … self.weatherRequestCancellable = self.weatherClient .weather(location.woeid) .receive(on: self.mainQueue) …
— 15:56
And then the only compiler error left in this module is when constructing the view model in the SwiftUI preview. Here it is totally appropriate to hand it a live dispatch queue since we’ll be running the feature in a preview, and so we can even use DispatchQueue.main : viewModel: AppViewModel( locationClient: .notDetermined, pathMonitorClient: .satisfied, weatherClient: .happyPath, mainQueue: DispatchQueue.main.eraseToAnyScheduler() )
— 16:27
So our SwiftUI preview should work exactly as it did before, but we are now in a much better position to write exact, deterministic tests.
— 16:44
If we build tests we will see we have a few failures because we are constructing the view model without providing a scheduler. If we were to provide a regular dispatch queue then the tests should behave exactly as they did before. For example, in the testBasics test we could do: let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: WeatherClient( weather: { _ in .init(.moderateWeather) }, searchLocations: { _ in .init([.brooklyn]) } ), mainQueue: DispatchQueue.main.eraseToAnyScheduler() )
— 17:07
But that’s not wielding the new power we have. We can put in a completely different scheduler here, one that is not susceptible to the vagaries of the outside world.
— 17:22
The scheduler we will use is a test scheduler, which can be created from any scheduler type by calling a static property on the type: func testBasics() { let mainQueue = DispatchQueue.testScheduler … }
— 17:37
Then we can erase this scheduler and provide it to the view model: let viewModel = AppViewModel( … mainQueue: mainQueue.eraseToAnyScheduler() )
— 17:41
This should get this test building, but all the other tests are still having problems. Let’s quickly comment them out just so that we can see how this test behaves.
— 17:53
If we now run tests we will see we get two failures. In fact, it’s the same two failures we had back when we used a dispatch queue. This is happening because a test scheduler does not execute any work until we tell it to. We tell it to by telling it to advance its internal time forward. We can even tell it to advance by a certain amount of time, or we can simply tell it to advance, which just means it will execute whatever work is on the queue waiting to be run: // _ = XCTWaiter.wait(for: [XCTestExpectation()], timeout: 0.01) self.mainQueue.advance()
— 18:55
And now tests magically pass! The pass instantly and deterministically, with no waiting. This test will never intermittently fail just because the computer is bogged down or something is taking longer to execute than normal. It will only fail if we got our assertions wrong.
— 19:16
Let’s update the rest of the tests. The next one, testDisconnected , doesn’t even use a scheduler so we can just stick in the test scheduler and it should still pass: func testDisconnected() { let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .unsatisfied, weatherClient: .unimplemented, mainQueue: mainQueue.eraseToAnyScheduler() ) XCTAssertEqual(viewModel.currentLocation, nil) XCTAssertEqual(viewModel.isConnected, false) XCTAssertEqual(viewModel.weatherResults, []) }
— 19:31
We don’t have access to this mainQueue scheduler because it was created in the other test case. It seems we are going to need to create one of these for nearly every test case, so let’s hoist it up to be an instance variable on the test case class: class WeatherFeatureTests: XCTestCase { let mainQueue = DispatchQueue.testScheduler … }
— 19:47
Though this test doesn’t even require a scheduler, and indeed if we run it, it passes.
— 19:54
So maybe it would be even better if we were to plug in the .failing scheduler we built in the last episode to strengthen this test. In fact, since the last episode we have released an update to the Combine Schedulers library that gives us access to the .failing scheduler. let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .unsatisfied, weatherClient: .unimplemented, mainQueue: .failing )
— 20:01
This test still passes, which proves that the feature we are testing makes no use of asynchrony, which is pretty cool.
— 20:09
Next let’s uncomment the testPathUpdates test. This will actually make use of the main queue scheduler, so let’s plug it into the view model: let viewModel = AppViewModel( … mainQueue: self.mainQueue.eraseToAnyScheduler() )
— 20:13
That gets the test building, but running it shows some failures. We need to advance the scheduler in a few key places to force the scheduler to execute its queued work, which will deliver data to the view model.
— 20:18
The first spot we have to do this is when the pathUpdateSubject is sent a .satisifed value, which indicates that the device now has internet connectivity. When that happens we fire off an API request to get the newest weather, and so that means we want to advanced the test scheduler: pathUpdateSubject.send(.init(status: .satisfied)) self.mainQueue.advance()
— 20:42
W later send another .satisfied value to the pathUpdateSubject to signify that the device has gotten internet connectivity after having previously lost it, which causes yet another API request to be triggered. If we advance the scheduler one more time we should get a fully passing test: pathUpdateSubject.send(.init(status: .satisfied)) self.mainQueue.advance()
— 21:08
And it does!
— 21:10
In the next test, testLocationAuthorization , we test the flow of the user tapping on the location button, we are asked for authorization, we grant authorization, and then finally the current location and weather is automatically fetched without any further user action. To get this test passing we just need to advance the scheduler after the user taps the location button: self.mainQueue.advance()
— 21:39
And our final test shouldn’t have any asynchrony involved at all, so we should put in a failing scheduler: mainQueue: .failing
— 22:12
And now the whole test suite is passing and it runs super fast. Pretty much instantly. We don’t need to artificially wait for time to pass just so that code interacting with dispatch queues can execute, and there is no flakiness whatsoever. It’s fast, concise, deterministic, and that’s what you want in a test suite. Immediate schedulers
— 22:28
But that doesn’t mean things can’t be a little better 😁.
— 22:32
The test scheduler’s primary purpose is to control the flow of time in tests. This typically means Combine operators like delay , debounce , timeout or throttle are involved because they specifically deal with units of time that you are waiting. However, whenever we use the .receive(on:) operator we are performing simple thread hopping. We just wanna execute some work on another queue as soon as possible. There’s no real passing of time.
— 22:57
And we can clearly see that this kind of scheduling is a bit different from using the other time-based Combine operators because the ergonomics of using test schedulers is not all that great when dealing with the .receive(on:) operator. Let’s take a look at why that is and what can be done about it.
— 23:15
If we scan through our test file we will see a bunch of spots we are using a test scheduler and then advancing it just so that we can force it to execute its work: let viewModel = AppViewModel( … mainQueue: self.mainQueue.eraseToAnyScheduler() ) … self.mainQueue.advance() …
— 23:31
Wouldn’t it be better if we could supply a scheduler that just executed its work immediately? There would be no need to tell it to advance. If we use a .receive(on:) then the scheduler should just immediately invoke the action.
— 23:44
Well, it turns out the Combine framework actually ships with a scheduler that aims to solve this problem. It’s called ImmediateScheduler , and if you schedule some work with it it is performed… well, immediately. For example, we could try scheduling some work that should occur after waiting 100 seconds like this: ImmediateScheduler.shared .schedule(after: ImmediateScheduler.shared.now.advanced(by: 100)) { print("hi") }
— 24:25
Note that ImmediateScheduler.SchedulerTimeType does not have any publicly available constructors, but we can still construct values by taking the current now value and advancing it forward since it’s Strideable .
— 24:50
If we run this code in a playground we will see that it prints immediately. We definitely did not need to wait 100 seconds for it to print.
— 24:57
So this is looking promising. Perhaps we can just plug in this immediate scheduler into our view model and then we could get rid of all the explicit .advance calls: let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: WeatherClient( weather: { _ in .init(.moderateWeather) }, searchLocations: { _ in .init([.brooklyn]) } ), mainQueue: ImmediateScheduler.shared.eraseToAnyScheduler() ) Cannot convert value of type ‘AnyScheduler<ImmediateScheduler.SchedulerTimeType, ImmediateScheduler.SchedulerOptions>’ (aka ‘AnyScheduler<ImmediateScheduler.SchedulerTimeType, Never>’) to expected argument type ‘AnySchedulerOf<DispatchQueue>’ (aka ‘AnyScheduler<DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>’)
— 25:17
Well, not so fast.
— 25:19
The ImmediateScheduler type that ships with Combine has its own associated types, and they do not match the types we use in the view model, in particular `DispatchQueue’s associated types. So we cannot plug in an immediate scheduler here.
— 25:46
And to be honest, we’re not entirely sure how to use Combine’s ImmediateScheduler . Because it has its own associated types, if you want to use an ImmediateScheduler in a testing context while still being open to use a regular dispatch queue in a production context you are forced to introduce generics like we discussed earlier. You seem to have no choice but to genericize AppViewModel : public class AppViewModel<S: Scheduler>: ObservableObject { … }
— 26:12
But we already saw the problems with this approach. This generic will infect every piece of code that touches the view model that also wants to be testable. This means other view models, SwiftUI views, and more.
— 26:21
There is one other approach we could also take. Right now we are erasing the scheduler type in the most minimal way possible. We have this: let mainQueue: AnySchedulerOf<DispatchQueue>
— 26:33
And this means we want to erase the type of scheduler it is, e.g. we don’t know if it’s a DispatchQueue or a TestScheduler , but we want to retain all of the associated types of DispatchQueue . We do this in case we need to make use of specific qualities of the associate types, such as needing to manipulate the DispatchTime values or making use of dispatch options.
— 26:54
We could however erase more from the type. Right now this syntax: let mainQueue: AnySchedulerOf<DispatchQueue>
— 26:58
is really just a shorthand for this syntax: let mainQueue: AnyScheduler< DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions >
— 27:14
And this syntax is just our ad hoc attempt at emulating a feature that hopefully Swift will someday have first class support for, and that’s existential types. Theoretically the Swift compiler could write all of the code in our AnyScheduler type behind the scenes if we just wrote out some specific syntax to describe what we want. This syntax could potentially look like this: let mainQueue: any Scheduler where .SchedulerTimeType == DispatchQueue.SchedulerTimeType, .SchedulerOptions == DispatchQueue.SchedulerOptions
— 27:58
It describes all the same things, but Swift would take care of all the messy details behind the scenes for us.
— 28:05
But we could take this a bit further. What if we didn’t care about the scheduler options and we wanted to further erase that? Well then we could just drop it from the where clause: let mainQueue: any Scheduler where .SchedulerTimeType == DispatchQueue.SchedulerTimeType
— 28:14
This erases the options so that we have no idea what kind of options are being used under the hood. For all intents and purposes it might as well be an Any type.
— 28:22
We could go even further and erase the time type too: let mainQueue: any Scheduler
— 28:26
Now we have erased everything except for the fact that we have a scheduler. We have no idea what the underlying concrete scheduler is and we have no idea what type of time or options are used.
— 28:37
So we are seeing essentially 3 different kinds of type erasure for the Scheduler protocol, and there is even an additional one where we erase the time type but not the options.
— 28:47
If Swift did support this feature then we could just 100% erase the type of the scheduler, associated types and all, and that would allow us to plug in ImmediateScheduler s just as easily as it is to plug in DispatchQueue s.
— 28:49
However, because Swift doesn’t have first class support this feature it would be up to us to write explicit type erasing wrappers for all 4 flavors of erasing, or at least the flavors we are most interested in. We definitely don’t want to go down that route, and luckily we don’t have to.
— 29:15
It is actually pretty straightforward to write an immediate scheduler from scratch. We have no idea what Combine’s implementation looks like, but there is no magic to it. Let’s give it a shot.
— 29:25
We can start much in the same way we constructed failing schedulers in the last episode. We’ll extend the Scheduler type to add a static property that returns the immediate scheduler, which will be an AnyScheduler : extension Scheduler { static var immediate: AnySchedulerOf<Self> { .init( minimumTolerance: <#() -> SchedulerTimeIntervalConvertible & Comparable & SignedNumeric#>, now: <#() -> Strideable#>, scheduleImmediately: <#(Self.SchedulerOptions?, @escaping () -> Void) -> Void#>, delayed: <#(Strideable, SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, Self.SchedulerOptions?, @escaping () -> Void) -> Void#>, interval: <#(Strideable, SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, Self.SchedulerOptions?, @escaping () -> Void) -> Cancellable#> ) } }
— 29:53
The first requirement is easy to fill in because SchedulerTimeType has a static .zero value: minimumTolerance: { .zero },
— 30:00
The now requirement is a little trickier so let’s leave that for a second.
— 30:04
The schedulerImmediate requirement is easy to implement because we’ll just invoke the action closure passed to us immediately: scheduleImmediately: { options, action in action() },
— 30:16
We can even ignore the options argument to make it super explicit we aren’t using it at all: scheduleImmediately: { _, action in action() },
— 30:23
And we can do the same for the last two requirements: delayed: { _, _, _, action in action() }, interval: { _, _, _, _, action in action(); return AnyCancellable {} }
— 30:51
Here it is very clear that this is an immediate scheduler because when it is asked to schedule something it does so immediately. It completely ignores duration, interval, tolerance etc.
— 31:02
The now requirement is still left, and we have the same problem with it that we did with our failing schedulers. We can’t generically fill in this requirement because we don’t have enough information about SchedulerTimeType to construct it. To fix this we need to upgrade the static var to a static func so that a value can be passed in to stand in for now : extension Scheduler { static func immediate( now: SchedulerTimeType ) -> AnySchedulerOf<Self> { .init( minimumTolerance: { .zero }, now: { now }, scheduleImmediately: { _, action in action() }, delayed: { _, _, _, action in action() }, interval: { _, _, _, _, action in action() return AnyCancellable {} } ) } }
— 31:28
This is the core of what we want from our immediate scheduler, but we can make it a little nicer. Right now every time we construct one of these schedulers we’ll have to supply a now value. So we can provide a convenience to construct immediately schedulers for particular types of schedulers, such as dispatch queues: extension Scheduler where SchedulerTimeType == DispatchQueue.SchedulerTimeType, SchedulerOptions == DispatchQueue.SchedulerOptions { static var immediate: AnySchedulerOf<Self> { .immediate(now: .init(.now())) } }
— 32:35
Let’s take it for a spin!
— 32:40
In our very first test we can swap out the test scheduler for an immediate scheduler: let viewModel = AppViewModel( … mainQueue: .immediate )
— 32:47
And even better we can get rid of the line that advances the test scheduler: // self.mainQueue.advance()
— 32:53
If we run tests we will see everything still passes. This means we have still taken control over time, because otherwise we’d have to use XCTestExpectation in order to wait for actual time pass, but we are ok with all of time being squashed down to a single instant rather than needing to explicit advance time forward.
— 33:13
We can do the same in the testPathUpdates test, where we substitute in an immediate scheduler: // mainQueue: self.mainQueue.eraseToAnyScheduler() mainQueue: .immediate
— 33:23
And we get rid of explicitly advancing the scheduler: // self.mainQueue.advance() … // self.mainQueue.advance()
— 33:29
And lastly we need to do the same with the testLocationAuthorization test: // mainQueue: self.mainQueue.eraseToAnyScheduler() mainQueue: .immediate … // self.mainQueue.advance() Where immediacy fails us
— 33:45
So this greatly enhances the ergonomics of our tests without sacrificing the speed, conciseness or determinism of the tests. Sounds like a win-win!
— 33:55
Now it isn’t true that immediate schedulers should replace test schedulers 100% of the time when simple asynchrony operators are involved, like .receive(on:) and .subscribe(on:) . There are still times that a test scheduler is necessary to assert on what happens between an asynchronous operation kicking off and it finishing.
— 34:34
To explore this, let’s add a quick feature to our view model. We’re going to add a new endpoint that cancels any inflight API requests being made. Perhaps there’s a “Cancel” button in the UI that the user can tap: func cancelButtonTapped() { }
— 35:06
To cancel the API requests we just need to clear out the cancellables that we are currently holding onto: func cancelButtonTapped() { self.searchLocationsCancellable = nil self.weatherRequestCancellable = nil }
— 35:32
We’re not going to bother hooking this up to the UI, but we encourage our viewers to try it out.
— 35:40
We will however write tests for this. We can start by copying and paste the testBasics test, renaming it, and then invoking viewModel.cancelButtonTapped right after the view model is constructed. We would hope this cancels the inflight work and prevents us from getting data back for currentLocation and weatherResults : func testCancellation() { let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: WeatherClient( weather: { _ in .init(.moderateWeather) }, searchLocations: { _ in .init([.brooklyn]) } ), mainQueue: .immediate ) viewModel.cancelButtonTapped() XCTAssertEqual(viewModel.currentLocation, nil) XCTAssertEqual(viewModel.isConnected, true) XCTAssertEqual(viewModel.weatherResults, []) }
— 36:25
However, when we run this the test fails. This is because we are using an immediate scheduler, and so no matter how quickly we invoke cancelButtonTapped that ship has already sailed. It is not possible to observe what happens between the moment we create the view model and the moment our mock API client feeds data back into the system.
— 36:55
To get access to that fleeting moment we need to bring back the test scheduler, which only advances time when explicitly told to do so. mainQueue: self.mainQueue.eraseToAnyScheduler()
— 36:55
In fact, the test already passes, but in order to prove that there is no in-flight request just waiting to return to the view model, we should advancing the scheduler after we tap the cancel button: func testCancellation() { let viewModel = AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: WeatherClient( weather: { _ in .init(.moderateWeather) }, searchLocations: { _ in .init([.brooklyn]) } ), mainQueue: self.mainQueue.eraseToAnyScheduler() ) viewModel.cancelButtonTapped() self.mainQueue.advance() XCTAssertEqual(viewModel.currentLocation, nil) XCTAssertEqual(viewModel.isConnected, true) XCTAssertEqual(viewModel.weatherResults, []) }
— 37:37
This test now passes, and proves that if an API request is in flight, then tapping the “Cancel” button will definitely prevent those requests from completing and feeding their data back into the system. Next time: the point
— 38:21
And so that’s all there is two immediate schedulers, our 3rd “better test” dependencies in this series. Over the past 3 episodes we have shown that it is quite fruitful to explore ways to improve the ways we construct dependencies for our tests.
— 38:40
First we showed there’s a lot of power in being exhaustive with dependencies. It allows us to instantly see what a part of our feature is using a dependency we don’t expect, and it allows us to layer on new functionality and be instantly notified of what tests need to be updated.
— 38:54
However, the ergonomics of that weren’t great, and so we explored a way to improve the situation by failing the test suite when a dependency is incorrect accessed rather than crashing the suite. This came with some new complications, but ultimately we were able to workaround them.
— 39:09
And then finally we showed that just because we want to control asynchrony in our tests it doesn’t necessarily mean we need to use a TestScheduler , whose primary purpose is to control the flow of time in tests. Sometimes it’s perfectly fine to squash all of time into a single point, and that makes are tests even shorter and more concise.
— 39:25
And so it’s usually around this time the we ask the all important question “what’s the point?” This is our moment to bring things down to earth and show real world applications of the things we talk about.
— 39:40
Everything we’ve done so far has been quite real world, but we can still go deeper. We are going to demonstrate two important things:
— 39:49
Even though our focus of this episode has been on testing, there is an invariable certainty of programming that when you do a little bit of upfront work to make your code more testable or more easily tested you will naturally have other benefits that have nothing to do with tests. And so our focus on building better dependencies for tests actually have some really surprising and amazing applications to other parts of our development of applications.
— 40:14
And then we want to truly bring these ideas into the “real world” by showing how all of these techniques are incredibly important to how we developed our new iOS word game, isowords , which we also open sourced less than 2 weeks ago. There are some really cool things we can show off.
— 40:36
So let’s get started…next time! References Collection: Schedulers Brandon Williams & Stephen Celis • Jun 4, 2020 Note There’s a lot of great material in the community covering almost every aspect of the Combine framework, but sadly Combine’s Scheduler protocol hasn’t gotten much attention. It’s a pretty mysterious protocol, and Apple does not provide much documentation about it, but it is incredibly powerful and can allow one to test how time flows through complex publishers. https://www.pointfree.co/collections/combine/schedulers Designing Dependencies Brandon Williams & Stephen Celis • Jul 27, 2020 We develop the idea of dependencies from the ground up in this collection of episodes: Note Let’s take a moment to properly define what a dependency is and understand why they add so much complexity to our code. We will begin building a moderately complex application with three dependencies, and see how it complicates development, and what we can do about it. https://www.pointfree.co/collections/dependencies Composable Architecture: Dependency Management Brandon Williams & Stephen Celis • Feb 17, 2020 We made dependencies a first class concern of the Composable Architecture by baking the notion of dependencies directly into the definition of its atomic unit: the reducer. https://www.pointfree.co/collections/composable-architecture/dependency-management Composable Architecture Brandon Williams & Stephen Celis • May 4, 2020 The Composable Architecture is a library for building applications in a consistent and understandable way, with composition, testing and ergonomics in mind. http://github.com/pointfreeco/swift-composable-architecture Downloads Sample code 0140-better-test-dependencies-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 .