Video #219: Modern SwiftUI: Dependencies & Testing, Part 1
Episode: Video #219 Date: Jan 9, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep219-modern-swiftui-dependencies-testing-part-1

Description
Uncontrolled dependencies can wreak havoc on a modern SwiftUI code base. Let’s explore why, and how we can begin to control them using a brand new library.
Video
Cloudflare Stream video ID: 8b1060e090b640e2eee9f0fd7adefb64 Local file: video_219_modern-swiftui-dependencies-testing-part-1.mp4 *(download with --video 219)*
References
- Discussions
- our Clocks library
- Dependencies
- swift-dependencies
- open Swift bug reports
- Getting started with Scrumdinger
- SyncUps App
- SE-0374: Add
sleep(for:)toClock - Packages authored by Point-Free
- 0219-modern-swiftui-pt6
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
Alright, so we have now introduced 3 pretty complex effects into our application. We are dealing with timers that can be paused while alerts are up, we’re requesting speech authorization from the user and starting up a speech recognition task in parallel with the timer, and on top of all of that we are listening to any changes made to the array of standups, debouncing them for a second, and then persisting the data. Oh, and we also load that data on launch.
— 0:29
Without these effects our little demo was nothing more than a “cute” toy. Sure we had some fun interactions like sheets, drill downs and alerts, but everything was implemented with just simple state mutation. There was no interaction with the outside world. These effects have added a whole new dimension of behavior to the demo and turned it into a full blown application.
— 0:49
But with that new behavior comes new challenges. We have opened up Pandora’s box of complexity and unknowability in our codebase. We already saw this in concrete terms where we saw we have effectively broken the “record meeting” preview due to the fact that it interacts with Apple’s Speech framework directly, which does not work in Xcode previews. And we saw that when we added persistence we destroyed our ability to open up the application or preview into a state with a bunch of standups stubbed in because now that data has to come from the disk.
— 1:20
And if those problems weren’t bad enough, we also don’t have any hope of writing unit tests for any of those code. The Speech framework doesn’t work at all in unit tests, and because we have a real life timer we are going to have to wait for real life time in our tests, which will slow down the tests. And because we are reading and writing to the real disk we are going to have to be careful to clean up after tests, or else that data will start to leak across tests, causing mystifying test failures.
— 1:47
This is what motivates us to finally consider properly controlling our dependencies on things like timers, the Speech framework, and disk access. Doing so allows us to fix all of these problems and more.
— 2:00
So, let’s quickly look at all the problems that crop up when dealing with uncontrolled dependencies, and then let’s fix them. Uncontrolled dependencies
— 2:07
The first problem we encountered with uncontrolled dependencies had to do with the speech recognizer. We saw that speech tasks simply do not work in previews, so if we wanted to play around with any user flow involving speech transcribing we would be forced to do it all in the simulator, which breaks our quick iterative cycle.
— 2:24
For example, we can run the StandupDetail preview, start a meeting, and we see already the timer isn’t even counting up. That’s because our code is technically stuck trying to ask for authorization. However, we can manually end the meeting early, then we are popped back to the detail and see that a new historical meeting was added to the list, but if we drill down to that we will see there is no transcript.
— 2:59
We also previously saw that we took the time to handle any errors throw by the speech recognizer by showing an alert, but there was no way to actually see the alert.
— 3:11
This was true of both the simulator and preview. We know that errors can occur deep inside the Speech framework, as its recognition delegate can emit errors, but we don’t actually know the steps that can cause an error. So, we just have to hope that if the Speech framework throws an error we will handle it properly.
— 3:26
We also previously saw that once persistence was added to the application we lost the ability to start up an Xcode preview or the simulator with a specific collection of standups displayed. The StandupsListModel no longer takes standups as an argument, and this is because the initial array of items is now loaded from disk: init(destination: Destination? = nil) { … }
— 3:42
So, say we wanted to play around with the performance of the app when there are 1,000s of rows in the list. The only way to do this would be to overwrite the standups.json file on disk with all of that data before the StandupsListModel is initialized so that it will be picked up in the initializer.
— 4:00
That sounds pretty precarious and error prone, and worse, you would be overwriting the data on disk permanently. What if you wanted to keep that data intact while you test out this one little performance thing? It would be a bummer to lose your previous data just to do that.
— 4:13
So, over and over again we are seeing that uncontrolled dependencies are making our developer experience with this code base thornier. Each one in isolation maybe isn’t so bad, but it’s a “death by a thousand paper cuts” kind of situation, and all of these things add up to make it more miserable to work with this code base.
— 4:33
But perhaps even worse is how it makes writing tests nearly impossible. A few episodes ago we were able to write a test on some nuanced logic we had around deleting attendees from a meeting and refocusing fields: func testDeletion() { let editModel = EditStandupModel( standup: Standup( id: Standup.ID(UUID()), attendees: [ Attendee(id: Attendee.ID(UUID()), name: "Blob"), Attendee( id: Attendee.ID(UUID()), name: "Blob Jr" ), ] ) ) editModel.deleteStandups(atOffsets: [1]) XCTAssertEqual(editModel.standup.attendees.count, 1) XCTAssertEqual( editModel.standup.attendees[0].name, "Blob" ) XCTAssertEqual( editModel.focus, .attendee(editModel.standup.attendees[0].id) ) }
— 4:45
That test was super easy to write, executes immediately, and is 100% deterministic. But also it doesn’t involve any side effects. It’s just simple mutations of state. The whole feature is basically a pure function.
— 5:01
That is not true of many of the features we have built since this first test was written.
— 5:06
For example, suppose we wanted to test persistence. What we could do is create a model, simulate the user adding a new standup, then create a new model to represent launching the app again, and verify that the new model already has a standup stored in its array: import XCTest @testable import Standups @MainActor class StandupsListTests: XCTestCase { func testPersistence() { let listModel = StandupsListModel() XCTAssertEqual(listModel.standups.count, 0) listModel.addStandupButtonTapped() listModel.confirmAddStandupButtonTapped() XCTAssertEqual(listModel.standups.count, 1) let nextLaunchListModel = StandupsListModel() XCTAssertEqual( nextLaunchListModel.standups.count, 1 ) } }
— 6:19
Well, unfortunately this fails on this line: XCTAssertEqual(nextLaunchListModel.standups.count, 1) testPersistence(): XCTAssertEqual failed: (“0”) is not equal to (“1”)
— 6:24
This is happening because we debounce changes to the standups array so that we don’t thrash the disk with tons of writes if the data changes many times. And recall that we reached out to the global, uncontrollable main queue to perform that debounce scheduling work: self.standupsCancellable = self.$standups .dropFirst() .debounce( for: .seconds(1), scheduler: DispatchQueue.main ) .sink { standups in … }
— 6:49
So, the only way to get over the 1 second hump is to literally wait for 1 second to pass, although we should probably pad it a bit too: try await Task.sleep(for: .milliseconds(1_100)) let nextLaunchModel = StandupsListModel() XCTAssertEqual(nextLaunchListModel.standups.count, 1)
— 7:15
This passes! And the only way it could pass is if StandupsListModel really does load data from disk upon initializer, but also only if it was listening for changes to the standups array so that it could save that data to disk. So we really are getting some good test coverage on this behavior.
— 7:33
But also, it of course took over a second, which is a bummer: Test Suite 'StandupsListsTests' passed. Executed 1 test, with 0 failures (0 unexpected) in 1.109 (1.109) seconds
— 7:39
Tests should run in a fraction of second, not a full second. And what if in the future we decide to debounce for 5 seconds? Are we going to make this test even slower just to test that?
— 7:47
Now, maybe you don’t really care about how long the test takes. After all, 1 second isn’t terrible. Well, you will definitely care about the next problem. If we run the test again without a single change it will suddenly fail. In fact, every single assertion failed: testPersistence(): XCTAssertEqual failed: (“1”) is not equal to (“0”) testPersistence(): XCTAssertEqual failed: (“2”) is not equal to (“1”) testPersistence(): XCTAssertEqual failed: (“2”) is not equal to (“1”)
— 8:06
If I run the test again all the numbers go up: testPersistence(): XCTAssertEqual failed: (“2”) is not equal to (“0”) testPersistence(): XCTAssertEqual failed: (“3”) is not equal to (“1”) testPersistence(): XCTAssertEqual failed: (“3”) is not equal to (“1”)
— 8:15
And again: testPersistence(): XCTAssertEqual failed: (“3”) is not equal to (“0”) testPersistence(): XCTAssertEqual failed: (“4”) is not equal to (“1”) testPersistence(): XCTAssertEqual failed: (“4”) is not equal to (“1”)
— 8:23
This is happening because our previous test run has bled over into future test runs. Each test run we write a file to disk that has one additional standup in the collection. And then the next run of the test loads up that file with the additional standup.
— 8:35
We could try cleaning up data beforehand. But, the helper we defined for grabbing the file URL is private to the “standups list” feature, so we would either need to make it internal, which is strange, or we need to repeat that work in the test: try? FileManager.default.removeItem( at: .documentsDirectory.appending( component: "standups.json" ) )
— 9:20
Which is also strange, but it does at least get the test passing.
— 9:24
However, this is work we’ll have to do for any test involving the standups list, so we should probably move it into a setUp . override func setUp() { super.setUp() try? FileManager.default.removeItem( at: .documentsDirectory.appending( component: "standups.json" ) ) }
— 9:35
Let’s try writing one more test. The record feature is quite significant and complex, and so we would love to get some test coverage on it. Perhaps the simplest thing we could test is that if we emulate the user navigating to the screen we should be able to wait until the timer finishes, and then assert that the screen should be dismissed.
— 9:51
Naively, we may try to do it like this: import XCTest @testable import Standups @MainActor class RecordMeetingTests: XCTestCase { func testTimer() async { var standup = Standup.mock standup.duration = .seconds(6) let recordModel = RecordMeetingModel( standup: standup ) await recordModel.task() XCTAssertEqual(recordModel.secondsElapsed, 6) XCTAssertEqual(recordModel.dismiss, true) } }
— 10:55
However, if we run the test it seems to just hang. Now we do have a live timer in the feature code, so we would expect to wait some time. But we purposefully set the standup duration to 6 seconds so that we wouldn’t have to wait forever, and this seems to be taking forever.
— 11:17
Well, I just so happen to know that there is something funky going on over in the simulator, so let’s hop over to it.
— 11:23
The speech authorization alert is up! It turns out that iOS app tests must run in a simulator host, and because we are calling out to real Speech framework code it is causing our model’s code to suspend until this alert is dealt with.
— 11:37
This of course is not going to play nicely with continuous integration servers. There isn’t going to be someone over there to tap a button on this alert. So, this going to completely block our ability to write unit tests, but let’s just close the alert to see what else happens. I will not give permission.
— 11:51
Now after 6 seconds the method does un-suspend, and the test mostly passes, but there is one failure: testTimer(): Unimplemented: RecordMeetingModel.onMeetingFinished … Defined at: Standups/RecordMeeting.swift:15 Invoked with: ""
— 12:00
This is actually a great failure to have. It is telling us that we are writing a test that exercises the behavior of when the record feature tells the parent feature it has finished, but we haven’t explicitly asserted on that behavior. This is forcing us to make an assertion, and then in the future if ever accidentally break that functionality we will immediately get a test failure.
— 12:17
We can get test coverage on that parent-child communication mechanism by using an expectation: func testTimer() async { var standup = Standup.mock standup.duration = .seconds(6) let recordModel = RecordMeetingModel(standup: standup) let expectation = self.expectation( description: "onMeetingFinished" ) recordModel.onMeetingFinished = { _ in expectation.fulfill() } await recordModel.task() self.wait(for: [expectation], timeout: 0) XCTAssertEqual(recordModel.secondsElapsed, 6) XCTAssertEqual(recordModel.dismiss, true) }
— 12:55
And now this passes, and we are testing a pretty complex piece of functionality in the record feature. But of course it is super precarious because at the end of the day the state of the simulator can completely block up our test, and also the test has to wait for a real life timer to count down in order to make the assertion. Controlling dependencies
— 13:15
So, while there are a lot of things to love about how we have structured our code base so far, in particular the fact that all navigation is fully driven off of state and that we can deep link into any state imaginable, there are still some sharp edges that need to be smoothed over.
— 13:28
Let’s start attacking these on-by-one.
— 13:30
We’ll start with one of the easier ones: controlling the timer.
— 13:33
We recently had a 2-part series of episodes where we went deep into this topic, but the short of it is that when you need to perform sleeps in your code, it is far better to use clocks than to reach out to the real world, uncontrollable Task.sleep . Then you can use a live clock when running the app in the simulator and device, and use a more appropriate clock for tests and previews.
— 13:53
And we even open sourced a library that specifically enables us to code in this style. I’m talking about our Clocks library , which comes with some helpful new Clock protocol conformances, such as TestClock , ImmediateClock and more, as well as an asynchronous timer.
— 14:09
Let’s see how we can use clocks to better handle our timer, and make the test we just wrote run super fast.
— 14:17
Let’s start by having our RecordMeetingModel hold onto an actual clock that we will use for performing sleeps rather than reaching out to Task.sleep : class RecordMeetingModel: ObservableObject { private let clock: any Clock<Duration> … }
— 14:42
And we will add the clock to the initializer of the model so that it can be controlled from the outside, but just so that we don’t have to fix all places where this model is constructed we will give it a default of a real life continuous clock: init( clock: any Clock<Duration> = ContinuousClock(), destination: Destination? = nil, standup: Standup ) { self.clock = clock self.destination = destination self.standup = standup }
— 15:12
Then, where we were previously performing Task.sleep we can now just do clock.sleep : while true { try await self.clock.sleep(until: <#Instant#>) … }
— 15:24
However, unfortunately, the Clock protocol does not have a sleep(for:) method like Task does, so we can say “sleep for 1 second”. Instead we need to give an absolute instant of time to sleep until. This is just an oversight in Swift, and a near future Swift will have such a method and it will even be back deployed to Swift 5.7.
— 15:54
Until then we can depend on our Clocks library that provides that method, as well as a bunch of helpers and controllable clocks that we will soon need access to. So, let’s import it: import Clocks
— 16:13
And let’s have Xcode add the package and link with our application.
— 16:24
And now we can do: while true { try await self.clock.sleep(for: .seconds(1))
— 16:33
Even better, now that we have an actual clock at our disposal, we can make use of the timer AsyncSequence it exposes instead of just sleeping for a second. The reason we would want that is because sleeps are not extremely precise. A sleep for 1 second can actually take more than a second of time, depending on various factors. And those small discrepancies can accumulate over time, causing a 5 minute timer to actually take 5 and a half minutes or more.
— 17:02
The timer that is defined on clock in our library takes care to adjust the sleeps based on how much it has deviated from the correct time, making it much more precise: for await _ in self.clock.timer(interval: .seconds(1)) { … }
— 17:31
We can even move the check for whether or not an alert is presented into the for await loop: for await _ in self.clock.timer(interval: .seconds(1)) where !self.isAlertOpen { self.secondsElapsed += 1 … }
— 17:37
With those few changes the code works exactly as it did before. We can see that by running the preview.
— 17:55
And we can run the test…
— 18:15
The test is still slow, but at least it passes.
— 18:23
But now we are in a position to make this code a lot better. We can provide what is known as an “immediate” clock to the model in tests: import Clocks … let recordModel = RecordMeetingModel( clock: ImmediateClock(), standup: standup )
— 18:38
It’s a clock that does not actually suspend for any amount of time when you tell it to sleep. It just advances its internal time forward to the deadline immediately. This is perfect for pushing through time-based asynchrony for when you don’t actually care about the amount of time passing.
— 19:03
And with that one small change, tests still pass and do so pretty much immediately: Test Suite 'RecordMeetingTests' passed. Executed 1 test, with 0 failures (0 unexpected) in 0.062 (0.063) seconds
— 19:06
The test now takes a fraction of a second. In fact, we can run this test nearly 100 times in the same amount of time it would have take the old test to run a single time.
— 19:18
So, this already seems like a big win, but this style of holding onto dependencies can be problematic, and it all has to do with the choice we made in the initializer: init( clock: any Clock<Duration> = ContinuousClock(), destination: Destination? = nil, standup: Standup ) {
— 19:31
We have two choices here, and they each trade off safety for ergonomics or vice versa.
— 19:36
Currently we are providing a default clock, the real life continuous one, which is super ergonomic in that no other part of the code base has to change when we added this dependency. But it’s also not super safe. If a parent feature is using a clock too, then it should remember that whenever it constructs a RecordMeetingModel it should pass along its clock, otherwise the two features could be using different clocks.
— 20:01
And in fact, this is already the situation in this code base. The StandupDetail feature uses Task.sleep to suspend for a brief amount of time before inserting the newly recorded meeting into the history array: try? await Task.sleep(for: .milliseconds(400)) withAnimation { _ = self.standup.meetings.insert( Meeting(id: UUID(), date: Date(), transcript: transcript), at: 0 ) }
— 20:19
Let’s quickly update this code to use a clock. We will add the clock to StandupDetailModel as a dependency: import Clocks @MainActor class StandupDetailModel: ObservableObject { … private let clock: any Clock<Duration> … init( clock: any Clock<Duration> = ContinuousClock(), destination: Destination? = nil, standup: Standup, ) { self.clock = clock … } … }
— 20:38
And then we can make use of it: try? await self.clock.sleep(for: .milliseconds(400))
— 20:51
That’s all it takes, and this code should work exactly as it did before.
— 20:57
But the strange thing is that in this feature we are constructing a RecordMeetingModel and not passing along the clock: func startMeetingButtonTapped() { self.destination = .record( RecordMeetingModel(standup: self.standup) ) }
— 21:12
That means the record feature will be using a continuous clock, even though the detail feature may be using some other kind of clock. In particular, if we were writing a test for the detail feature that exercised some logic in the record feature too, it would be impossible for us to make sure the record feature is using an immediate clock. To fix this we have to explicitly pass along the clock when creating a RecordMeetingModel : func startMeetingButtonTapped() { self.destination = .record( RecordMeetingModel( clock: self.clock, standup: self.standup ) ) }
— 21:37
But it is on us to make sure to do this. Nothing is forcing us to do it or letting us know that it should be done.
— 21:43
On the other hand, if we did not provide a default in the initializer: init( clock: any Clock<Duration>, destination: Destination? = nil, standup: Standup ) {
— 21:48
…then it would be much safer because we would require anyone constructing this model to provide a clock. But at the same time we would destroy the ergonomics. Adding a clock to the record feature means we have to also add a clock to the detail feature, which means we have to also add a clock to the list feature, and on and on and on. This makes adding dependencies to any feature a pain, and doubly so for deep leaf features.
— 22:11
This dichotomy we are witnessing between providing defaults and not is the exact same situation we came across when dealing with delegate closures for parent-child communication. We found that we could provide a default and be ergonomic, but not safe. Or we could not provide a default, which would be safe, but not ergonomic.
— 22:33
So, we went with a middle ground to try to get a little bit of the best of both worlds. We default such closures to an “unimplemented” closure: var onMeetingFinished: (String) -> Void = unimplemented( "RecordMeetingModel.onMeetingFinished" )
— 22:42
This is a closure that has special behavior. If it is called in the simulator you get a purple Xcode warning letting you know that the feature has not been properly integrated with the parent feature. And if it is called during a test you get a test failure. Importing Dependencies
— 22:54
We’d love to have a middle ground for our dependencies like we had for these delegate closures. something that makes using a dependency easy where we don’t have to change a bunch of code, but also safe so that we make sure we use dependencies in the correct way.
— 23:09
And luckily we have open sourced a library just for that. It’s called “ Dependencies ”, and it was originally built to make dependency management easy in the Composable Architecture , but it applies just as well to vanilla SwiftUI applications too.
— 23:23
So, let’s add it to our project and see what it unlocks.
— 23:27
We can import it: import Dependencies
— 23:33
And have Xcode add the package and link with our application.
— 23:40
With that done we can make use of a special property wrapper the library vends called @Dependency . It is very similar to @Environment in that you provide a key path in order to specify which dependency you want access to: @Dependency(\.<#⎋#>)
— 24:01
In fact, if we hit the ESC key on the key path we will see all the dependencies that the library comes with pre-integrated. Each of these dependencies have been specifically designed to be controllable for tests and previews. It is also possible for you to register your own dependencies with the library so that they appear in this list.
— 24:18
It just so happens that the Dependencies library comes with a fully controllable clock: @Dependency(\.continuousClock) var clock We can even decide between continuous and suspending, but we will go with continuous for now.
— 24:41
With that we no longer need to pass a clock to the initializer.
— 24:49
So that already removes the question of whether or not we provide a default. Also, everything in this file is already compiling. The only compiler error is over in the detail view.
— 24:55
We no longer have to pass along a clock when creating a RecordMeetingModel .
— 25:51
And we can use @Dependency to grab a controllable clock for the StandupDetailModel , and we no longer have to pass a clock to the initializer: @Dependency(\.continuousClock) var clock
— 25:23
Everything compiles, and everything should work exactly as it did before. But now, secretly, both of these features are using the same clock even though we are not explicitly passing clocks around.
— 25:32
Let’s see how this change affected tests. Right now tests are not compiling because we no longer have to pass a clock when creating the model: let recordModel = RecordMeetingModel(standup: standup)
— 25:48
That gets things compiling, but it also seems a little weird. Previously we were passing a clock so that we could squash time with the immediate clock. If we aren’t passing in a clock explicitly, what kind of clock is the model going to use? A real life clock?
— 26:02
Well, let’s run the test to find out: testTimer(): Unimplemented: ContinuousClock.now testTimer(): Unimplemented: ContinuousClock.sleep
— 26:07
We get test failures. This is letting us know that we are using a live dependency in our feature but we haven’t overridden it. This is a great failure to have because it forces us to control any dependencies in our feature for a test so that we don’t accidentally reach out to real, external services and processes.
— 26:27
The Dependencies framework comes with a helper that allows you to override tests for a well-defined scope. It’s called withTestValues , and it’s defined as a static method on DependencyValues , which is the global collection of dependencies registered in the application. It takes two trailing closures: DependencyValues.withTestValues { <#inout DependencyValues#> in <#code#> } assert: { <#code#> } Correction Between the time of recording this episode and open sourcing our swift-dependencies we changed the design of this API. It now looks like this: withDependencies { <#inout DependencyValues#> in <#code#> } operation: { <#code#> }
— 26:51
The first is handed a mutable copy of the current dependencies and you are free to mutate it however you feel. This is the place where it is appropriate to override any dependencies you think your feature makes use of in the user flow you are testing, such as the clock: DependencyValues.withTestValues { $0.continuousClock = ImmediateClock() } assert: { <#code#> }
— 27:19
Then you put all your assertion code in the last trailing closure. So we will move all of the test code that constructs a model and hits its endpoints into the closure: DependencyValues.withTestValues { $0.continuousClock = ImmediateClock() } assert: { var standup = Standup.mock standup.duration = .seconds(6) let recordModel = RecordMeetingModel(standup: standup) let expectation = self.expectation( description: "onMeetingFinished" ) recordModel.onMeetingFinished = { _ in expectation.fulfill() } await recordModel.task() self.wait(for: [expectation], timeout: 0) XCTAssertEqual(recordModel.secondsElapsed, 6) XCTAssertEqual(recordModel.dismiss, true) } Correction Thanks to some improvements we made to swift-dependencies after recording this episode, the above test can now be written like so: var standup = Standup.mock standup.duration = .seconds(6) let recordModel = withDependencies { $0.continuousClock = ImmediateClock() } operation: { RecordMeetingModel(standup: standup) } let expectation = self.expectation( description: "onMeetingFinished" ) recordModel.onMeetingFinished = { _ in expectation.fulfill() } await recordModel.task() self.wait(for: [expectation], timeout: 0) XCTAssertEqual(recordModel.secondsElapsed, 6) XCTAssertEqual(recordModel.dismiss, true) Note that only the model needs to be constructed in the scope of withDependencies . All of the assertions can happen outside.
— 27:27
And because the assertion code uses async we need to mark withTestValues with async: await DependencyValues.withTestValues { … } assert: { … }
— 27:35
This compiles, but we now have two concurrency warnings: Non-sendable type ‘() async throws -> ()’ exiting main actor-isolated context in call to non-isolated static method ‘withTestValues(_:assert:)’ cannot cross actor boundary Non-sendable type ‘(inout DependencyValues) async throws -> Void’ exiting main actor-isolated context in call to non-isolated static method ‘withTestValues(_:assert:)’ cannot cross actor boundary
— 27:39
Remember that we decided to turn concurrency warnings to their max so that we could catch problems like this early.
— 27:44
This is likely a bug or deficiency in sendability and global actors, and there are open Swift bug reports for similar warnings, but luckily we can work around it by being a little more careful/precise with where we annotate things with a global actor.
— 28:03
So, the fix is to not mark our test as @MainActor , and instead move it to marking the async assert trailing closure as @MainActor : // @MainActor final class RecordMeetingTests: XCTestCase { func testTimer() async { await DependencyValues.withTestValues { $0.continuousClock = ImmediateClock() } assert: { @MainActor in … } } }
— 28:14
Now this compiles with no warnings, and it passes! And it even passes super quickly: Test Suite 'RecordMeetingTests' passed. Executed 1 test, with 0 failures (0 unexpected) in 0.101 (0.102) seconds
— 28:20
The fact that this passes, and does so quickly, is proof that we are overriding all dependencies used in this execution flow and that the feature is definitely using an immediate clock and not accidentally reaching out to the uncontrollable Task.sleep method.
— 28:33
There’s another quick win we can take in the root standups list feature. Recall that currently we are reaching out to the global, uncontrollable main queue to perform debouncing work: self.standupsCancellable = self.$standups .dropFirst() .debounce( for: .seconds(1), scheduler: DispatchQueue.main ) .sink { standups in … }
— 28:44
This made it difficult to write a test for persistence since we had to literally wait for time to pass.
— 28:48
Well, just as our Dependencies library comes with controllable clocks, it also comes with controllable Combine schedulers. We can add a mainQueue dependency to our feature’s model: @Dependency(\.mainQueue) var mainQueue
— 29:07
And then use that instead of reaching out to DispatchQueue.main : self.standupsCancellable = self.$standups .dropFirst() .debounce( for: .seconds(1), scheduler: self.mainQueue ) .sink { standups in … }
— 19:14
With that change our persistence test immediately starts failing because we are accessing an uncontrolled dependency in tests: testPersistence(): @Dependency(\.mainQueue) - An unimplemented scheduler scheduled an action to run on a timer.
— 29:25
We can now make use of DependencyValues.withTestValues to override the mainQueue dependency and then execute our tests: DependencyValues.withTestValues { $0.mainQueue = <#???#> } assert: { … }
— 29:48
But, what kind of scheduler do we use? The Dependencies library gives us access to an “immediate” scheduler that is similar to an “immediate” clock, but unfortunately due to how debounce is implemented in Combine, immediate schedulers cannot be used.
— 30:08
Instead we have to use a “test” scheduler, which just takes a few extra steps to implement: func testPersistence() { let mainQueue = DispatchQueue.test DependencyValues.withTestValues { $0.mainQueue = mainQueue.eraseToAnyScheduler() } assert: { let listModel = StandupsListModel() XCTAssertEqual(listModel.standups.count, 0) listModel.addStandupButtonTapped() listModel.confirmAddStandupButtonTapped() XCTAssertEqual(listModel.standups.count, 1) mainQueue.run() let nextLaunchListModel = StandupsListModel() XCTAssertEqual( nextLaunchListModel.standups.count, 1 ) } }
— 30:55
OK, so we have greatly improved this persistence test. We are no longer waiting for a full second to pass to verify the logic, thanks to us controlling the main queue scheduler.
— 31:07
But there is still some weird stuff in this test. Recall that we have a setUp whose whole purpose is to clear out the file system so that one test doesn’t bleed into another. Next time: custom dependencies
— 31:16
We can make all of this much better if we finally take control over our dependency on the file system. In particular, the saving and loading of data to the file system.
— 31:23
Our Dependencies library does not come with such a client immediately available to us, but it is quite easy to create. This will give us a chance to show off how one registers a new dependency with the library so that it is immediately available everywhere via the @Dependency property wrapper. References Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger SyncUps App Brandon Williams & Stephen Celis A rebuild of Apple’s “Scrumdinger” application that demosntrates how to build a complex, real world application that deals with many forms of navigation (e.g., sheets, drill-downs, alerts), many side effects (timers, speech recognizer, data persistence), and do so in a way that is testable and modular. https://github.com/pointfreeco/syncups Dependencies Brandon Williams & Stephen Celis • Jan 9, 2022 An open source library of ours. A dependency management library inspired by SwiftUI’s “environment.” https://github.com/pointfreeco/swift-dependencies Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger Clocks Brandon Williams & Stephen Celis • Jun 29, 2022 An open source library of ours. A few clocks that make working with Swift concurrency more testable and more versatile. https://github.com/pointfreeco/swift-clocks SE-0374: Add sleep(for:) to Clock Brandon Williams & Stephen Celis • Sep 19, 2022 A Swift Evolution proposal from yours truly that introduced a sleep(for:) method to Clock , making it possible for clock existentials to sleep. https://github.com/apple/swift-evolution/blob/main/proposals/0374-clock-sleep-for.md Packages authored by Point-Free Swift Package Index These packages are available as a package collection, usable in Xcode 13 or the Swift Package Manager 5.5. https://swiftpackageindex.com/pointfreeco Downloads Sample code 0219-modern-swiftui-pt6 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 .