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

Description
We conclude the series by taking control of the last two dependencies: persistence and speech recognition. We’ll make use of even more features of our new Dependencies library and we’ll write tests for our features that would have been impossible before.
Video
Cloudflare Stream video ID: caa1cbb8d9bdd920f64c5bd4247e9329 Local file: video_220_modern-swiftui-dependencies-testing-part-2.mp4 *(download with --video 220)*
References
- Discussions
- Mocks Aren’t Stubs
- our Custom Dump library
- SwiftUI Navigation
- combine-schedulers
- swift-case-paths
- swift-clocks
- swift-dependencies
- swift-identified-collections
- swift-tagged
- xctest-dynamic-overlay
- the Composable Architecture
- Getting started with Scrumdinger
- SyncUps App
- combine-schedulers
- Packages authored by Point-Free
- 0220-modern-swiftui-pt7
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
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.
— 0:12
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. Data manager dependency
— 0:35
Let’s add a few more tests for the StandupsListModel . It has 2 other pretty significant pieces of logic. It is responsible for navigating to the detail screen, and it must implement two integration points. From the detail screen one can delete the standup, which notifies the parent to actually do the deletion: standupDetailModel.onConfirmDeletion = { [weak self, weak standupDetailModel] in guard let self, let standupDetailModel else { return } withAnimation { self.standups.remove( id: standupDetailModel.standup.id ) self.destination = nil } }
— 1:18
And from the detail screen one can make edits to the standup which must be replayed back to the main array of standups at the root: self.destinationCancellable = standupDetailModel.$standup .sink { [weak self] standup in guard let self else { return } self.standups[id: standup.id] = standup }
— 1:19
Both of these pieces of logic were tricky to get right, and so we would love to have some test coverage on it.
— 1:34
Let’s start with the edit functionality. We can get the basics of a test into place: func testEdit() { let mainQueue = DispatchQueue.test DependencyValues.withTestValues { $0.mainQueue = mainQueue.eraseToAnyScheduler() } assert: { let listModel = StandupsListModel() } }
— 1:41
Next we want to emulate the user drilling down to the detail screen. However, the only way to do that is if we actually have a standup in the model’s array for us to drill down to.
— 2:28
Recall that we were forced to remove the standups array from the initializer of the model when we added persistence. That was because we started loading the initial data from disk, and so it no longer made sense to also allow passing in an initial array of standups when creating the model.
— 2:34
This is going to make writing this test very annoying. Before we can actually get to the thing we want to test we need to go through all the user actions to actually add a standup to the list: func testEdit() { 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) } }
— 3:11
It is going to be a real pain if we have to do this every time we want to test a flow that requires some standups to be in the list. So between this problem and the problem of accounting for persistence bleeding across tests is making it clear that we should be controlling our dependency on loading and saving data on disk.
— 3:36
The interface we want of our data client is that it can load data from a URL and save data to a URL. This can be done most simply as: struct DataManager { var load: (URL) throws -> Data var save: (Data, URL) throws -> Void }
— 4:00
Then we register this dependency with the library by first making it conform to the DependencyKey protocol, which requires us to provide a “live” implementation of the client. This is the client that can actually interact with the real Foundation APIs for reading and writing data to disk: extension DataManager: DependencyKey { static let liveValue = DataManager( load: { url in try Data(contentsOf: url) }, save: { data, url in try data.write(to: url) } ) }
— 5:07
Then the second part to registering the dependency is to add a computed property to DependencyValues that locates the dependency via its key: extension DependencyValues { var dataManager: DataManager { get { self[DataManager.self] } set { self[DataManager.self] = newValue } } } These steps are basically exactly what one does to add an environment value to SwiftUI.
— 5:42
We are getting one warning though because remember we have concurrency warnings maxed out: Type ‘DataManager’ does not conform to the ‘Sendable’ protocol
— 5:53
Since dependencies can be used from concurrent contexts, they must be concurrency safe. This means the dependency must be Sendable , which we can do simply: struct DataManager: Sendable { var load: @Sendable (URL) throws -> Data var save: @Sendable (Data, URL) throws -> Void }
— 6:14
With that done we can start using the dependency. We can add it to the model: @MainActor class StandupsListModel: ObservableObject { @Dependency(\.dataManager) var dataManager … }
— 6:31
And then use it in the initializer for both the loading and saving logic: init(destination: Destination? = nil) { self.destination = destination self.standups = [] self.bind() do { let data = try self.dataManager.load(.standups) self.standups = try JSONDecoder().decode( IdentifiedArray.self, from: data ) } catch { self.standups = [] } self.standupsCancellable = self.$standups .dropFirst() .debounce( for: .seconds(1), scheduler: self.mainQueue ) .sink { [weak self] standups in guard let self else { return } do { try self.dataManager.save( JSONEncoder().encode(standups), .standups ) } catch { } } }
— 7:23
With this dependency controlled we now have a shot running tests in a controlled environment that doesn’t reach out to the device disk. This means we should be able to prevent disk changes from leaking out to other tests, which caused all types of problems and forced us to do manual clean up in the setUp method.
— 7:45
If we run the persistence test we wrote previously we will get our first hint that something isn’t right: testPersistence(): @Dependency(\.dataManager) has no test implementation, but was accessed from a test context: Location: Standups/StandupsList.swift:53 Dependency: DataManager Dependencies registered with the library are not allowed to use their default, live implementations when run from tests. To fix, override ‘dataManager’ with a mock value in your test. If you are using the Composable Architecture, mutate the ‘dependencies’ property on your ‘TestStore’. Otherwise, use ‘DependencyValues.withValues’ to define a scope for the override. If you’d like to provide a default value for all tests, implement the ‘testValue’ requirement of the ‘DependencyKey’ protocol.
— 8:03
All the assertions passed just fine in the test, but we got a failure right when creating the model letting us know that we are accessing a “live” dependency in a test context.
— 8:14
This is an amazing failure to have, and we get it basically for free from the Dependencies library. We get instant feedback from the library when we are using a live dependency in a test, which the vast majority of times is not the right thing to do. If you truly do want to use a live dependency, then you must override it to tell the library you explicitly intend to use a live value: DependencyValues.withTestValues { $0.dataManager = .liveValue $0.mainQueue = scheduler.eraseToAnyScheduler() } assert: { … }
— 8:46
With that the test passes, but because we are using the liveValue it means we are actually reading and writing to disk, and so we have to keep around that setUp logic for cleaning up after ourselves.
— 8:55
What if instead we could construct a whole new DataManager implementation that mimics what happens when when interacting with the file system, but just skips the file system.
— 9:06
This is quite easy to do. We can just keep around a bit of local mutable Data , return it in the load endpoint and overwrite it in the save endpoint: static var mock: DataManager { var data = Data() return DataManager( load: { _ in data }, save: { newData, _ in data = newData } ) }
— 9:58
However, mutable variables don’t play nicely with @Sendable closure: Reference to captured var ‘data’ in concurrently-executing code
— 10:02
This is not a valid thing to do in Swift, and it’s great that it’s catching us from doing this. If these closures were merely escaping, then this would not be an error. It’s only an error because Swift knows @Sendable closures can be executed concurrently, and therefore it is invalid to capture mutable data. If you want to learn more about this we highly recommend you watch our series of episodes on concurrency.
— 10:26
Luckily the Dependencies library comes with a tool for us to quickly wrap up some mutable data in a Sendable package, and it’s called LockIsolated : static var mock: DataManager { let data = LockIsolated(Data()) return DataManager( load: { _ in data.value }, save: { newData, _ in data.setValue(newData) } ) }
— 10:55
Now this compiles with no warnings or errors, and it’s the perfect sandbox for playing around with loading and saving data, all without touching the global, mutable, shared file system.
— 11:06
For example, let’s copy-paste our persistence test, but use this mock data manager instead of the live one: func testPersistenceMock() { let mainQueue = DispatchQueue.test DependencyValues.withTestValues { $0.dataManager = .mock $0.mainQueue = mainQueue.eraseToAnyScheduler() } assert: { let listModel = StandupsListModel() XCTAssertEqual(listModel.standups.count, 0) listModel.addStandupButtonTapped() listModel.confirmAddStandupButtonTapped() XCTAssertEqual(listModel.standups.count, 1) scheduler.run() let nextLaunchListModel = StandupsListModel() XCTAssertEqual( nextLaunchListModel.standups.count, 1 ) } }
— 11:24
This passes, and it does so without any clean up. We can comment out the setUp code and it still works. Since we completely controlled the data access there is no possibility of the writes of want test to bleed into the reads of another.
— 11:41
Now, some our viewers may have heard somewhere that “mocks” are bad. Now, we personally aren’t actually aware of the “true” definition of mock, if there even is one. And beyond mocks, there’s something called stubs, and fakes, and spies, and there’s even an article called “ Mocks Aren’t Stubs .”
— 11:57
We really don’t want to get dragged in to a terminology and semantics discussion here because we just don’t think it’s important. We are dealing with something very concrete here. When we say “mock” we just mean an implementation of a dependency that we don’t own in order to provide some behavior that deviates from the live implementation. In this case, returns data immediately and does not interact with the file system.
— 12:22
One of the reasons people say mocks are bad is that they cause you to write tests that test the mock, and not the real code. For example, if we were doing something silly like this: dataManager.save(Data("hello".utf8)) XCTAssertEqual(dataManager.load(…), Data("hello".utf8))
— 12:49
That is only exercising code in the mock dependency and none of the code in our feature.
— 12:54
And that is not what is happening in our test. We are legitimately testing our actual code and how it interacts with the data manager. If we were to comment out anything in the initializer we could get an instant test failure.
— 13:40
What we are not testing here is the file system. We have removed it out of the equation because it’s a global, shared, mutable blob of data that everyone can instantly observe, and we don’t own that code so we just have to assume that Apple is doing the right thing when we say “please load the data at this URL”. We just don’t need to test that code.
— 14:03
What we do need to test is how the behavior of loading and saving data flows through our feature and affects our behavior.
— 14:10
Now, maybe every once in awhile you do want to write a test that uses the live dependency. That is totally fine, and can be handy, we just don’t think it’s the majority use case since so much care has to be done to clean up the mess left over from other tests.
— 14:29
Further, by controlling the dependency we get exercise code paths that would otherwise be impossible by accessing the real file system. For example, the save method on DataManager can throw an error, but do you know how to actually make it throw? I’m guessing it would happen if the disk is out of space, or if you don’t have permissions to write to the URL, but do we really want to try to emulate those situations in a test? Would it be far better to just construct a DataManager implementation that fails when trying to write: static let failToWrite = DataManager( load: { url in Data() }, save: { data, url in struct SaveError: Error {} throw SaveError() } ) Then you would be able to confirm that your logic deals with that error correctly. Maybe it shows an alert to the user, or logs some analytics.
— 15:09
Similarly you could construct an implementation that fails when loading: static let failToLoad = DataManager( load: { _ in struct LoadError: Error {} throw LoadError() }, save: { newData, url in } )
— 15:32
OK, with all that said, let’s go back to our test for testing the edit flow. The whole reason we went on this little side excursion to control file access is because we want to be able to start the model in a state where data is already provided without needing to muck around with the file system and without needing to emulate the user adding standups.
— 15:55
What we can do is beef up our mock implementation to allow passing in the initial data that the mock starts with, rather than just using empty Data() under the hood: static func mock( initialData: Data = Data() ) -> DataManager { let data = LockIsolated(initialData) return DataManager( load: { _ in data.value }, save: { newData, url in data.setValue(newData) } ) }
— 16:41
Then we can update our edit test to override the data manager dependency so that it starts with the data from an array of a single standup: $0.dataManager = .mock( initialData: try JSONEncoder().encode([Standup.mock]) ) And with that we can confirm that the model does indeed start up with a single model: let listModel = StandupsListModel() XCTAssertEqual(listModel.standups.count, 1)
— 17:31
OK, we’re finally in a good position to actually write out a sequence of user actions that the user performs on the screen, and then assert on how state changes. We can start by emulating the user tapping on the standup to drill down: listModel.standupTapped(standup: listModel.standups[0]) guard case let .some(.detail(detailModel)) = listModel.destination else { XCTFail() return } XCTAssertEqual( detailModel.standup, listModel.standups[0] )
— 18:32
This test is passing, which means that definitely when the taps the standup, the destination points to the detail cause, and that model holds the standup we just tapped on. That means if we can trust SwiftUI to do the right thing with this data, we have write an actual test proving that the drill-down animation will happen.
— 18:56
Next we can emulate the user opening the edit screen for the standup: detailModel.editButtonTapped() guard case let .some(.edit(editModel)) = detailModel.destination else { XCTFail() return } XCTAssertEqual(editModel.standup, detailModel.standup)
— 19:42
This test passes, and so again, if we can trust that SwiftUI will do the right thing we can rest assured that when the “Edit” button is tapped, a sheet will fly up.
— 19:54
Next we can emulate the user making edits to the standup, like say changing the title, and then tapping the “Done” button: editModel.standup.title = "Product" detailModel.doneEditingButtonTapped()
— 20:09
This should cause the detail model’s destination to go nil , since the sheet should close, and further the detail’s standup state should be mutated based on the edits we just made: XCTAssertNil(detailModel.destination) XCTAssertEqual(detailModel.standup.title, "Product")
— 20:30
Finally, we can emulate the user popping back to the list view. There is no method on the model for this because it happens when the user taps the back button, which is natively provided by SwiftUI, or when they swipe on the screen. So the only thing we can do is manually nil out destination to represent navigating back: listModel.destination = nil
— 20:53
And finally we can confirm that the standup in the list model’s array has been updated to reflect the edits made by the user: XCTAssertEqual(listModel.standups[0].title, "Product")
— 21:07
And amazingly this all passes.
— 21:09
This is an incredibly nuanced and complex test that is exercises how 3 completely separate features are integrated together. There are many things we can get wrong in that feature code, but we now have a test that proves everything is hooked up correctly.
— 21:27
For example, in the list model we have special logic for listening to any changes of the standup in the detail view so that we can play them back to the standup in the array. This logic is what allows the edits to be visible in the root list. If we didn’t have that logic: // self.destinationCancellable // = standupDetailModel.$standup // .sink { [weak self] standup in // guard let self else { return } // self.standups[id: standup.id] = standup // }
— 21:43
…then we immediately get a test failure: testEdit(): XCTAssertEqual failed: (“Design”) is not equal to (“Product”) This shows that our edit to the “Design” standup to rename it to “Product” was not properly saved to the root list view.
— 22:08
As another example, consider the logic we have for when the “Edit” button is tapped in the detail view: func editButtonTapped() { self.destination = .edit( EditStandupModel(standup: self.standup) ) } This is pretty simple. It just points the destination state to the edit case and passes along the current standup.
— 22:22
Suppose for a moment that we got a little confused and thought that we needed to construct a whole new standup to pass along to this model: func editButtonTapped() { self.destination = .edit( EditStandupModel( standup: Standup( id: Standup.ID(UUID()), attendees: self.standup.attendees, duration: self.standup.duration, meetings: self.standup.meetings, theme: self.standup.theme, title: self.standup.title ) ) ) }
— 22:54
This would have been the correct logic if we were implementing a “duplicate” feature, and so maybe we just weren’t thinking clearly.
— 23:04
Well, with that change we have broken tests. First, it’s no longer true that the standup state matches in both the edit and detail model: XCTAssertEqual(editModel.standup, detailModel.standup) testEdit(): XCTAssertEqual failed: (“Standup(id: E845C2E2-F90F-4A51-B3F5-401A782709DA, attendees: IdentifiedArray ([Standups.Attendee(id: 99DEABFA-870A-4F0F-97C7-98EF2AE743F8, name: “Blob”), Standups.Attendee(id: F77B1C2E-1E5E-4D35-BCEF-081EE028E83F, name: “Blob Jr”), Standups.Attendee(id: BBAC9630-11DD-4CA0-86AF-1EF823ECAB52, name: “Blob Sr”), Standups.Attendee(id: 9FA7A58D-A5B8-4CA7-B97B-8C8669A86C9F, name: “Blob Esq”), Standups.Attendee(id: 55D19564-589E-4CB3-B563-B2A54383A265, name: “Blob III”), Standups.Attendee(id: 77E443D8-9AEE-4200-ABB9-80E76E5D51B5, name: “Blob I”)]), duration: 60.0 seconds, meetings: IdentifiedArray ([Standups.Meeting(id: 882F2F87-9947-454B-941C-1911A7BA934A, date: 2022-11-08 16:21:47 +0000, transcript: “Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.”)]), theme: Standups.Theme.orange, title: “Design”)”) is not equal to (“Standup(id: 3FC3D749-1A19-4297-A148-F494C2E787AE, attendees: IdentifiedArray ([Standups.Attendee(id: 99DEABFA-870A-4F0F-97C7-98EF2AE743F8, name: “Blob”), Standups.Attendee(id: F77B1C2E-1E5E-4D35-BCEF-081EE028E83F, name: “Blob Jr”), Standups.Attendee(id: BBAC9630-11DD-4CA0-86AF-1EF823ECAB52, name: “Blob Sr”), Standups.Attendee(id: 9FA7A58D-A5B8-4CA7-B97B-8C8669A86C9F, name: “Blob Esq”), Standups.Attendee(id: 55D19564-589E-4CB3-B563-B2A54383A265, name: “Blob III”), Standups.Attendee(id: 77E443D8-9AEE-4200-ABB9-80E76E5D51B5, name: “Blob I”)]), duration: 60.0 seconds, meetings: IdentifiedArray ([Standups.Meeting(id: 882F2F87-9947-454B-941C-1911A7BA934A, date: 2022-11-08 16:21:47 +0000, transcript: “Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.”)]), theme: Standups.Theme.orange, title: “Design”)”)
— 23:18
The error message is a total mess. I have absolutely no idea what is different between these two values.
— 23:28
Luckily for us we can make use of our Custom Dump library , which we get transitively from SwiftUI Navigation . It comes with a helper that can provide concise error messages for non-equaling values: import CustomDump … XCTAssertNoDifference(editModel.standup, detailModel.standup)
— 23:51
Now we see that the failure is that the UUID’s don’t match: testEdit(): XCTAssertNoDifference failed: … Standup( − id: UUID(AA8B99CF-E82D-4188-8228-28CE947396A3), + id: UUID(0029A792-4487-407F-A0B8-9B630320D4CC), attendees: […], duration: Duration(…), meetings: […], theme: Theme.orange, title: "Design" ) (First: −, Second: +)
— 23:56
Now it’s clear that the two data types only differ because of their ID. The messaging as even helpfully collapsed sub-collections that are identical.
— 24:05
And so this is expected because we decided to construct a whole new standup when editing instead of passing along the one we already had access to.
— 24:14
The next error is far more interesting: XCTAssertEqual(listModel.standups[0].title, "Product") testEdit(): XCTAssertEqual failed: (“Design”) is not equal to (“Product”)
— 24:16
This is happening because when we try updating the standups array it is not actually working because the standup has an incorrect ID: self.standups[id: standup.id] = standup
— 24:40
This shows that the small mess up all the way over in the detail screen has caused a test failure all the way back in the list feature, where it is no longer true that the standup’s edits become visible to the root.
— 25:11
So, this is really amazing.
— 25:13
We are getting deep test coverage on our features, including how multiple features integrate together. And this is all possible thanks to our insistence to using @ObservedObject s and integrating all of the models together instead of using @StateObject s and having each feature be an isolated island that stands on its own. Speech client dependency
— 25:30
There’s a whole bunch more tests we can write too.
— 25:33
We could write a test for the deletion flow of a standup. This involves multiple steps. Starting the test off in a state with at least one standup, drilling down to the detail, tapping the delete button, asserting that an alert was shown, tapping the confirm deletion button, and then asserting that the screen popped back to the root and the standup was removed from the collection.
— 25:52
We could also write a test on the record screen that confirms that the timer pauses when the end meeting alert is presented. We could do this using a test clock to show that when the alert is up, if we advance the clock the elapsedSeconds state does not change. But then if we cancel the alert and advance the clock, we will observe the state changing.
— 26:12
These are all possible, and we have a whole bunch of exercises at the bottom of our episode page encouraging you to try them out, but let’s end with something even cooler.
— 26:21
We still have one major dependency in our code, and it not only wreaks havoc in our tests, but also our previews. And that’s the speech client. Let’s control this last dependency, and so what kind of super powers that gives us.
— 26:35
Right now we have sprinkled little bits of Speech framework code throughout the record feature.
— 26:52
That got the job done, but saw over and over it caused problems.
— 27:01
For example, it caused one of our tests to hang because secretly behind the scenes a speech authorization alert was up, and we had to actually interact with the alert to get the test to move forward. We also saw that we couldn’t play around with the functionality of starting a meeting, ending a meeting, and then seeing a transcript in the history. The Speech framework just doesn’t work at all in previews.
— 27:19
So, let’s take back control over our code rather than letting this dependency control us.
— 27:25
Let’s start a new file called SpeechClient.swift , and I’m going to paste in the very basic interface we need in order to interact with the Speech framework: @preconcurrency import Speech struct SpeechClient { var authorizationStatus: @Sendable () -> SFSpeechRecognizerAuthorizationStatus var requestAuthorization: @Sendable () async -> SFSpeechRecognizerAuthorizationStatus var startTask: @Sendable (SFSpeechAudioBufferRecognitionRequest) async -> AsyncThrowingStream< SpeechRecognitionResult, Error > } struct SpeechRecognitionResult: Equatable { var bestTranscription: Transcription var isFinal: Bool } struct Transcription: Equatable { var formattedString: String } extension SpeechRecognitionResult { init( _ speechRecognitionResult: SFSpeechRecognitionResult ) { self.bestTranscription = Transcription( speechRecognitionResult.bestTranscription ) self.isFinal = speechRecognitionResult.isFinal } } extension Transcription { init(_ transcription: SFTranscription) { self.formattedString = transcription.formattedString } }
— 27:47
This just lets us get the current authorization, request authorization, and start a speech task. We’ve also taken the extra step of providing simple data type wrappers around some of the Speech framework types, which helps with testing and previews since many of those types are not constructible.
— 28:04
Next we will perform the two step process for registering this dependency with our Dependencies library. First step is conform it to the DependencyKey protocol, which requires us to provide a liveValue . We will just make use of that Speech object we pasted in from before: extension SpeechClient: DependencyKey { static var liveValue: Self { let speech = Speech() return Self( authorizationStatus: { SFSpeechRecognizer.authorizationStatus() }, requestAuthorization: { await withCheckedContinuation { continuation in SFSpeechRecognizer .requestAuthorization { status in continuation.resume(returning: status) } } }, startTask: { request in await speech.startTask(request: request) } ) } } private actor Speech { var audioEngine: AVAudioEngine? = nil var recognitionTask: SFSpeechRecognitionTask? = nil var recognitionContinuation: AsyncThrowingStream< SpeechRecognitionResult, Error >.Continuation? func startTask( request: SFSpeechAudioBufferRecognitionRequest ) -> AsyncThrowingStream< SpeechRecognitionResult, Error > { AsyncThrowingStream { continuation in self.recognitionContinuation = continuation let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory( .record, mode: .measurement, options: .duckOthers ) try audioSession.setActive( true, options: .notifyOthersOnDeactivation ) } catch { continuation.finish(throwing: error) return } self.audioEngine = AVAudioEngine() let speechRecognizer = SFSpeechRecognizer( locale: Locale(identifier: "en-US") )! self.recognitionTask = speechRecognizer.recognitionTask( with: request ) { result, error in switch (result, error) { case let (.some(result), _): continuation.yield( SpeechRecognitionResult(result) ) case (_, .some): continuation.finish(throwing: error) case (.none, .none): fatalError( """ It should not be possible to have \ both a nil result and nil error. """ ) } } continuation.onTermination = { [audioEngine, recognitionTask] _ in _ = speechRecognizer audioEngine?.stop() audioEngine?.inputNode.removeTap(onBus: 0) recognitionTask?.finish() } self.audioEngine?.inputNode.installTap( onBus: 0, bufferSize: 1024, format: self.audioEngine?.inputNode .outputFormat(forBus: 0) ) { buffer, when in request.append(buffer) } self.audioEngine?.prepare() do { try self.audioEngine?.start() } catch { continuation.finish(throwing: error) return } } } }
— 30:18
Then we can finish registering the speech client by adding a computed property to DependencyValues : extension DependencyValues { var speechClient: SpeechClient { get { self[SpeechClient.self] } set { self[SpeechClient.self] = newValue } } }
— 30:47
We are now able to add the speech client to the RecordMeetingModel via the @Dependency property wrapper: @MainActor class RecordMeetingModel: ObservableObject { … @Dependency(\.continuousClock) var clock @Dependency(\.speechClient) var speechClient … }
— 31:05
And instead of reaching out directly to the uncontrollable Speech frameworks APIs, we will make use of this speech client. For example, to start a speech client we will do: for try await result in await self.speechClient.startTask(request) { self.transcript = result.bestTranscription.formattedString }
— 31:25
And to ask for authorization we will do: await self.speechClient.requestAuthorization()
— 31:46
And just like that we have controlled yet another dependency in our application. Everything should run exactly as it did before, after all it is basically all the same code, just moved around a bit.
— 31:57
But now we have new powers. For example, as we mentioned a moment ago, the Speech framework doesn’t work at all in previews. Well, our Dependencies library has carved out a special little space specifically for previews.
— 32:08
If you add a static previewValue to your DependencyKey conformance, then that implementation of the dependency will be used during previews. For example, we could have an implementation of the SpeechClient interface that doesn’t touch Apple’s Speech APIs at all, and instead emulates all of its functionality. For example, it could fake its authorization status to just always return .authorized : extension SpeechClient { static var previewValue: Self { return Self( authorizationStatus: { .authorized }, requestAuthorization: { .authorized }, startTask: { _ in .never } ) } }
— 33:04
That would already be pretty useful in previews because previously we saw that the timer stopped working in previews since the requestAuthorization just hung forever. Now it works…
— 33:19
But even cooler, what if when we request to start a new speech task, we returned an async stream that simply emitted a bunch of “lorem ipsum” text: extension SpeechClient { static var previewValue: Self { let isRecording = LockIsolated(false) return Self( authorizationStatus: { .authorized }, requestAuthorization: { .authorized }, startTask: { _ in AsyncThrowingStream { continuation in Task { @MainActor in isRecording.setValue(true) var finalText = """ Lorem ipsum dolor sit amet, consectetur \ adipiscing elit, sed do eiusmod tempor \ incididunt ut labore et dolore magna \ aliqua. Ut enim ad minim veniam, quis \ nostrud exercitation ullamco laboris \ nisi ut aliquip ex ea commodo consequat. \ Duis aute irure dolor in reprehenderit \ in voluptate velit esse cillum dolore eu \ fugiat nulla pariatur. Excepteur sint \ occaecat cupidatat non proident, sunt in \ culpa qui officia deserunt mollit anim \ id est laborum. """ var text = "" while isRecording.value { let word = finalText.prefix { $0 != " " } try await Task.sleep( for: .milliseconds( word.count * 50 + .random(in: 0...200) ) ) finalText.removeFirst(word.count) if finalText.first == " " { finalText.removeFirst() } text += word + " " continuation.yield( SpeechRecognitionResult( bestTranscription: Transcription( formattedString: text ), isFinal: false ) ) } } } } ) } }
— 33:34
So, it doesn’t even pretend to try to transcribe audio. It just emits a bunch of nonsensical words so that we can see how our feature code deals with those emissions.
— 33:42
This allows us to hop over to the detail preview and see that a transcript is actually saved to the history. We can start a meeting, wait for a bit of time, then ending the meeting. We will see a historical meeting was added to the list. But even better, drilling down to the meeting shows a transcript with a bunch of lorem text. Had we waited longer in the meeting there would have been even more text.
— 34:14
It’s absolutely incredible that we are able to observe this kind of complex behavior between multiple features and their dependencies all from an Xcode preview. There is no reason to fire up a simulator just to see how this all works.
— 34:24
Controlling this dependency is also going to help with tests. Right now we have one test that exercises the timer behavior in the record feature. It was passing before, but now it fails: testTimer(): @Dependency(\.speechClient) has no test implementation, but was accessed from a test context:
— 34:41
This is letting us know that we are using a live speech client in tests. This is a great failure to have because as we saw before, live Speech code wreaks havoc on tests, including halting test execution while the speech permission alert is presented.
— 34:53
We now have the ability to completely bypass all speech recognition logic in the feature by just supplying SpeechClient that responds with denied when asked for authorization. Even better, we can just mutate just the requestAuthorization endpoint on the speech client directly in the withTestValues closure: func testTimer() async { await DependencyValues.withTestValues { $0.continuousClock = ImmediateClock() $0.speechClient.requestAuthorization = { .denied } } assert: { @MainActor in … } }
— 35:17
This passes, but even better it does not touch any Speech framework code at all. In fact, let’s delete the app from the simulator and resume the test so that we can see that the speech permission alert does not come up at all.
— 35:34
The test still passes, and does so immediately.
— 35:39
And just to prove that let’s remove that dependency override: // $0.speechClient.requestAuthorization = { .denied }
— 35:44
And run tests again.
— 35:46
They instantly start hanging, and if we check out the simulator we see the permission alert is up.
— 36:03
So, this is absolutely amazing. Controlling our dependencies helps every facet of building features, from tests to previews, and there are even some more use cases that we don’t even have time to discuss now, but hopefully someday soon we will.
— 36:15
There are still even more tests we could write. We haven’t yet verified any of the speech recognition behavior in a test, and so we highly recommend our viewers explore that. We have a bunch of exercises at the bottom of the episode page if you are interested.
— 36:26
Now that we are nearing the end of this series of episodes, I want to quickly take a look at the left sidebar of Xcode. Over the course of 4 episodes we have slowly added more and more of libraries in order to facilitate building a SwiftUI application in a modern, testable and scalable manner.
— 36:44
In fact, we are depending on 9 of our libraries: combine-schedulers swift-case-paths swift-clocks swift-custom-dump swift-dependencies swift-identified-collections swift-tagged swiftui-navigation xctest-dynamic-overlay
— 36:49
Most of these libraries aren’t even things we want to maintain. We think Apple should Sherlock nearly every one of these:
— 36:55
Combine Schedulers just makes Combine code most testable and controllable, and really should be a part of Combine proper. We don’t think that will ever happen since async/await is the future, but we really shouldn’t have to maintain this library.
— 37:06
Case Paths is just the analogy of key paths but for enums. If you think key paths are powerful, and you think enums are powerful, then case paths make a lot of sense. We really hope some day Swift has first class support for case paths.
— 37:19
The Clocks library, like our Combine Schedulers library, just brings testable and controllable clocks to Swift. It should probably be apart of the standard library right next to the Clock protocol.
— 37:28
The Custom Dump library is just a better way to dump and compare complex data structures. We used it to assert against two big pieces of state so that the test failure message could helpfully point out the exact discrepancy between the values. We also think Apple could do more in this area and so would hope we don’t have to maintain this library.
— 37:46
The Dependencies library is probably the only library Apple would never release themselves. It’s a pretty opinionated way of structuring dependencies, and they tend to keep out of such discussions.
— 37:56
The Identified Collections library is the perfect tool for modeling collections that are safe, performant and play nicely with SwiftUI. Apple should absolutely steal this library from us.
— 38:05
The Tagged library is great for creating type safety for identifiers and other types that carry semantic meaning beyond just being an integer, UUID, or what have you. This one probably not something Apple is interested in.
— 38:16
And then, as we’ve seen over and over, SwiftUI has some great navigation APIs, but they are sorely lacking when it comes to using enum state. Our SwiftUI Navigation library tries to bridge the gap by allowing you to model all of your feature’s destinations as a single enum, and it can be incredibly powerful. If Swift had first class support for case paths, then SwiftUI could make better APIs for navigation.
— 38:38
And finally, the XCTest Dynamic Overlay library just makes XCTFail usable from outside test targets, which can be really handy for building test tools. If Apple would make XCTFail usable everywhere, then we would happily deprecate this library.
— 38:53
In fact, all but one or two of these 9 libraries should probably be provided by Apple. We would happily deprecate them if Apple Sherlocked us. And this is why we don’t find it hard to recommend people make use of these libraries in their code bases. They are relatively un-opinionated and just fill in the gaps that Swift and Apple have not yet dealt with. Conclusion
— 39:11
It’s also worth noting that every one of those libraries, except for Tagged , originally started in the Composable Architecture and then we later decided to split them out into their own libraries. So, all of the concepts we discussed in the past 4 episodes also apply to applications built in the Composable Architecture.
— 39:27
OK, this has been an absolutely action packed series of episodes. We first walked through all of the code in one of Apple’s most interesting demo applications, Scrumdinger. There were some really interesting patterns in that code, but also some less than ideal things. And that’s totally fine, the point of that code base is not to show off the most pristine, perfect code base. Its point is to be approachable to hundreds of thousands of developers.
— 39:54
But then we wanted to take that code and show how to modernize it for those that do want to understand how to build a large, scalable, modern SwiftUI code base. One that cares about integrating many features together and facilitating parent-child communication patterns. One that cares about precise domain modeling and navigation patterns. One that cares about controlling dependencies and writing tests. And accomplishing all of that in a safe and ergonomic manner.
— 40:18
We’ve now accomplished all of that, and along the way uncovered some really amazing things.
— 40:22
Well, that’s it for this series. We have some really wonderful things to share in 2023, and we truly can’t wait.
— 40:34
Until next time! 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 CasePaths Brandon Williams & Stephen Celis • Aug 23, 2021 Custom Dump is one of our open source projects. It provides a collection of tools for debugging, diffing, and testing your application’s data structures. https://github.com/pointfreeco/swift-custom-dump SwiftUI Navigation Brandon Williams & Stephen Celis • Sep 7, 2021 A library we open sourced. Tools for making SwiftUI navigation simpler, more ergonomic and more precise. https://github.com/pointfreeco/swiftui-navigation 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 CasePaths Brandon Williams & Stephen Celis CasePaths is one of our open source projects for bringing the power and ergonomics of key paths to enums. https://github.com/pointfreeco/swift-case-paths 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 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 Identified Collections Brandon Williams & Stephen Celis • Jul 11, 2021 Identified Collections is our open source library that provides an ergonomic, performant way to manage collections of identifiable data, and fits in perfectly with SwiftUI. https://github.com/pointfreeco/swift-identified-collections Tagged Brandon Williams & Stephen Celis • Apr 16, 2018 Tagged is one of our open source projects for expressing a way to distinguish otherwise indistinguishable types at compile time. https://github.com/pointfreeco/swift-tagged XCTest Dynamic Overlay Brandon Williams & Stephen Celis • Mar 17, 2021 XCTest Dynamic Overlay is a library we wrote that lets you write test helpers directly in your application and library code. https://github.com/pointfreeco/xctest-dynamic-overlay 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 0220-modern-swiftui-pt7 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 .