Video #141: Better Test Dependencies: The Point
Episode: Video #141 Date: Apr 5, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep141-better-test-dependencies-the-point

Description
Crafting better test dependencies for our code bases come with additional benefits outside of testing. We show how SwiftUI previews can be strengthened from better dependencies, and we show how we employ these techniques in our newly released game, isowords.
Video
Cloudflare Stream video ID: 7f479c1928596a95b897ce049d88de8b Local file: video_141_better-test-dependencies-the-point.mp4 *(download with --video 141)*
References
- Discussions
- isowords
- App Store
- available to you on GitHub
- Composable Architecture
- 0141-better-test-dependencies-pt4
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
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.
— 0:23
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.
— 0:42
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.
— 0:56
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.
— 1:11
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.
— 1:27
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.
— 1:42
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:
— 1:51
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.
— 2:16
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.
— 2:39
So let’s get started! Immediacy in Xcode previews
— 2:41
We’re first going to take a look at the SwiftUI previews for our weather feature: struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView( viewModel: AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: .happyPath, mainQueue: DispatchQueue.main.eraseToAnyScheduler() ) ) } }
— 2:46
We currently have a single preview that shows what our UI looks like when we have location authorization, the network monitor is satisfied, and we have a “happy path” API client, which means we return successful mock data for each of its endpoints.
— 3:02
We’ve previously seen that it’s really powerful to be able to control these dependencies at this level because it means we can run this preview in a variety of environments. We could swap in a failing API client to see what happens when requests don’t properly load: weatherClient: .failed,
— 3:19
Or we could swap in a flakey network monitor client that toggles back and forth between satisfied and unsatisfied: pathMonitorClient: .flakey,
— 3:31
Or we could swap in a location client in which we have not yet authorized use: locationClient: .notDetermined,
— 3:42
All of these things are really cool and powerful, but why is it that there’s no actual data showing in our SwiftUI preview?
— 3:49
If we run the preview we do get data.
— 4:01
But as soon as we stop it the data goes away.
— 4:04
Maybe that’s not so bad? As long as data shows when running the preview, what’s the harm? Does it really matter that data doesn’t show until you are actually running the preview?
— 4:13
Well, it does matter, a lot. One of the more powerful features of SwiftUI previews is the fact that you can create multiple to show at once. This makes it easy to see your UI displayed in many different configurations all together.
— 4:30
For example, we can copy and paste our view and set an environment value to instantly see what our UI looks like in dark mode: static var previews: some View { ContentView( viewModel: AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: .happyPath, mainQueue: DispatchQueue.main.eraseToAnyScheduler() ) ) ContentView( viewModel: AppViewModel( locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: .happyPath, mainQueue: DispatchQueue.main.eraseToAnyScheduler() ) ) .environment(\.colorScheme, .dark) }
— 4:50
This allows us to iterate on our designs while simultaneously seeing what the screen looks like in light mode and dark mode, making it easy to catch when you accidentally introduce colors or styles that don’t work for one mode.
— 5:05
However, without any data showing in these views these previews aren’t super helpful. We can of course run the preview, but now we’ve completely destroyed the ability to iterate on these designs while simultaneously considering light and dark mode.
— 5:26
The reason this is happening is because SwiftUI previews only render what can be drawn immediately in the view. It does not allow any asynchrony. So if you have a single, innocent looking .receive(on:) in any of your view model code you instantly destroy its ability to be previewed because that introduces a thread hop when using a live DispatchQueue for your scheduler. Allowing this sneaky side effect to leak into our code has now affected our ability to take advantage of SwiftUI awesome preview feature.
— 6:04
Now some of our viewers may be wondering why are we even using a view model to show our previews. After all, SwiftUI views can kinda be thought of as just simple functions from data to view hierarchy, so shouldn’t we completely bypass the view model and just provide some data to a view?
— 6:20
Well, let’s explore some ways to do that real quick.
— 6:23
If views really are “simple” functions from data to view hierarchy, then where does that leave our ContentView which has an ObservedObject : public struct ContentView: View { @ObservedObject var viewModel: AppViewModel … }
— 6:45
In order to get at its “function-y” creamy nougat center we’d need to create a new view that just takes the data the view model exposes. We could even nest this view inside the ContentView type: public struct ContentView: View { … struct Core: View { var body: some View { } } … }
— 7:06
Then we would take everything in ContentView ’s body property and paste it into Core ’s body property.
— 7:18
And then we’d need to fix the compiler errors. Basically everywhere we reference the viewModel we should introduce a property and reference that instead.
— 7:22
For example, we can copy and paste the view model’s published properties but drop the @Published . Now the view is just holding onto simple, immutable data, so we can even change them from var to let , so long as we remove the defaults: let currentLocation: Location? let isConnected: Bool let weatherResults: [WeatherResponse.ConsolidatedWeather]
— 7:39
Then we can remove viewModel from wherever it references one of these properties. ForEach(self.weatherResults, id: \.id) { weather in … if !self.isConnected { … .navigationBarTitle(self.currentLocation?.title ?? "Weather")
— 7:53
We have one more view model reference, and that’s where we expose the location button tapped action. action: { self.onLocationButtonTapped() } This should also be exposed as simple data. So we’ll introduce a closure property that the view can call: let onLocationButtonTapped: () -> Void
— 8:07
So that in the button action closure we invoke this field instead of the view model: action: { self.onLocationButtonTapped() }
— 8:12
With that the Core view now builds in isolation, and we can call out to it from the ContentView ’s body: public var body: some View { Core( currentLocation: self.viewModel.currentLocation, isConnected: self.viewModel.isConnected, onLocationButtonTapped: self.viewModel.locationButtonTapped, weatherResults: self.viewModel.weatherResults ) }
— 9:11
With these changes the application should build and run just as before, but now in our previews we can use this core view rather than the ContentView : ContentView.Core( currentLocation: .some(.init(title: "Brooklyn", woeid: 1)), isConnected: true, onLocationButtonTapped: {}, weatherResults: [ .init( applicableDate: .init(), id: 1, maxTemp: 40, minTemp: 30, theTemp: 35 ), ] )
— 9:59
And we can do the same for a dark mode version: ContentView.Core( currentLocation: .some(.init(title: "Brooklyn", woeid: 1)), isConnected: true, onLocationButtonTapped: {}, weatherResults: [ .init( applicableDate: .init(), id: 1, maxTemp: 20, minTemp: 30, theTemp: 25 ), ] ) .environment(\.colorScheme, .dark)
— 10:18
Now our previews render immediately, but there are a few problems with this.
— 10:23
First, and most glaring, running the previews no longer provide an accurate picture of how the app will behave. It’s completely inert. It has no behavior. We will have to resort to running the screen in the simulator, which means building the full application which can take a very long time.
— 10:41
Further, running the Core view inside a preview is a bit like testing an implementation detail of some code. Sure we’re getting some verifiable results pretty easily, but it doesn’t reflect reality at all. In fact, I can completely comment out the body of ContentView and our previews don’t indicate at all that something sneaky has happened. And if we don’t have snapshot tests on our screens then these previews are the easiest way to see when something goes wrong in our code without resorting to running the full application in the simulator and clicking around on things.
— 11:19
And last but not least, its just extra code to maintain just so that we can get decent previews in Xcode. We have to create a whole new Core view for each view that has a view model, we have to maintain fields on both the view model and the core view, all to run a preview. And as our feature evolves, we need to maintain these two parallel worlds, where when a field is added, removed, or changed on our view model, we must also add, remove, or change it on its core view. That doesn’t seem right.
— 11:56
So this Core view isn’t going to be the right approach. Let’s back out of all those changes real quick.
— 12:06
An alternative is to still use the view model in the previews, but allow passing in data directly into the view model, not just dependencies. We could update the initializer to take all the data it holds onto: public init( currentLocation: Location? = nil, isConnected: Bool = true, weatherResults: [WeatherResponse.ConsolidatedWeather] = [], locationClient: LocationClient, pathMonitorClient: PathMonitorClient, weatherClient: WeatherClient, mainQueue: AnySchedulerOf<DispatchQueue> ) { self.currentLocation = currentLocation self.isConnected = isConnected self.weatherResults = weatherResults self.weatherClient = weatherClient self.locationClient = locationClient self.pathMonitorClient = pathMonitorClient self.mainQueue = mainQueue … }
— 12:50
And then in the preview in addition to passing in the dependencies we can pass in some data. For example, passing in the current location: ContentView( viewModel: AppViewModel( currentLocation: .init(title: "Brooklyn", woeid: 1), locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: .happyPath, mainQueue: DispatchQueue.main.eraseToAnyScheduler() ) )
— 13:13
And we already see “Brooklyn” in the UI. So this is promising. We can also supply some weather results: ContentView( viewModel: AppViewModel( currentLocation: .init(title: "New York", woeid: 1), weatherResults: [ .init( applicableDate: .init(), id: 1, maxTemp: 30, minTemp: 20, theTemp: 25 ) ], locationClient: .authorizedWhenInUse, pathMonitorClient: .satisfied, weatherClient: .happyPath, mainQueue: DispatchQueue.main.eraseToAnyScheduler() ) )
— 13:31
However, this does not seem to have show this data in the preview.
— 13:35
The reason is that when the view model is created we subscribe to the network monitor’s changes, which synchronously and immediately sends a .satisfied value, that then triggers us to refresh the weather: self.refreshWeather()
— 14:05
Which then clears out the weather results: self.weatherResults = []
— 14:21
So it doesn’t even matter that we provided our own mock weather results, they just immediately get cleared out anyway. It looks like this technique will work well for some of the data held in the view model, but not other parts of the data.
— 14:32
But even if this technique did work, it still wouldn’t be ideal:
— 14:35
We are again maintaining extra code just for previews. We need to maintain the initializer so that we can pass data into the view model in previews, but that functionality will never be used, and shouldn’t be used, anywhere else in our application.
— 14:50
Further, by supplying this escape hatch to stuff data into our view model we are missing out on capturing important behavior of our view and view model. For example, I can comment out the line in the view model that updates the current location with data retrieved from the location client: // self?.currentLocation = locations.first
— 15:14
The preview will keep humming along just fine but we have seriously broken our feature. The tests will of course catch it, but we could know even sooner if we had seen the problem in our preview.
— 15:33
Well, luckily for us there is a way to fix all the problems of the previous two attempts. We can capture the full, true behavior in the SwiftUI preview, without running it, and we don’t have to write extra code or create escape hatches to support it.
— 15:49
And it’s all thanks to the immediate scheduler! Let’s quickly back out of the changes we made that passed data into the initializer.
— 16:01
And down in our preview we can swap out the live DispatchQueue for an immediate version: // mainQueue: DispatchQueue.main.eraseToAnyScheduler() mainQueue: .immediate
— 16:16
Now when we run previews they instantly show the weather results, even though under the hood we are running real life Combine code in order to populate those fields of the view model.
— 16:50
If we make some changes to the view code, like say bump the font size of the day of week: Text( dayOfWeekFormatter .string(from: weather.applicableDate) .capitalized ) .font(.largeTitle)
— 17:01
We instantly see how this looks in both of our previews, the light and dark modes. If we were still at the mercy of the live DispatchQueue we’d have no choice but to run one of the previews, see how it looks, stop the preview, then start the other preview, and see how it looks. That completely destroys the awesome feedback cycle that SwiftUI previews gives us.
— 17:30
Even better, if we introduce a bug to our view model we will be able to see it instantly in the preview, without even needing to run the test suite. For example, suppose we commented out the line that actually sets the weather results once they load from the API: // self?.weatherResults = response.consolidatedWeather
— 17:45
Our preview now very clear shows that we are not showing any results, and so something must have gone wrong.
— 17:58
So, this is really awesome. We are still able to sculpt the exact kind of data that is fed into our views, but we are doing so through our dependencies and view model so that we get a further benefit of exercising some of their logic in our preview. It’s like getting two for the price of one!
— 18:27
And so while it is really powerful to think of SwiftUI views as simple functions from data to hierarchy, it’s still true at the end of the day there is behavior powering those views somewhere, and we don’t want to hide that away. We want it to be at the front of our minds in both SwiftUI previews and tests. Immediacy in isowords: previews
— 19:00
Now let’s switch gears a bit and show how important these new dependencies are to the way we develop our recently released iOS word game, isowords . It’s now in the App Store and we even open sourced the entire code base , so everything we’re about to show is fully available to you on GitHub .
— 19:19
We will start by taking a look at a feature of isowords, and that’s the leaderboard.
— 20:17
Let’s start in the LeaderboardFeature module, which is the module that handles all of the logic and functionality of isowords leaderboards, and let’s jump to LeaderboardResultsView .
— 20:28
LeaderboardResultsView is a generic view that powers all of our leaderboards, of which there are 3 types:
— 20:35
There’s the games leaderboard, allowing you to see top scores for timed and unlimited games, and you can scope the results to just show games from the past day, week or all time.
— 20:39
There’s the vocab leaderboard, which shows you all the top scoring games, and it too can be scoped to just the words in the past day, week or all time, along with a new feature we just added that shows “interesting” words. This is a fun way to surface words that may not be the highest scoring but were found in really interesting ways.
— 20:41
And then finally there’s the daily challenge results screen, which shows you how you rank for the daily challenge right now, but you can also bring up a calendar view to show your historical ranks and you can even view the results for any of those days.
— 20:43
All 3 of these very different screens are all powered by the same generic leaderboards component.
— 20:50
Down at the bottom of the file we’ve got some previews to test out some of the styles of leaderboard we just listed out. There’s one for vocab, daily challenge and solo games, and we’ve got a duplicate set of previews below that for seeing these screens in both light mode and dark mode at the same time.
— 21:08
Unfortunately, all of these previews show the UI in the loading state. On the one hand, this could be a good thing because maybe we want to make sure that loading state’s looking correct for all these variations. But on the other hand, what if we want to see the real data in the screen? We could of course run the preview, and then we start to seem something real:
— 21:42
In particular we can see there must be some decently complex logic powering these screens because just in this one screen we see that extra work is being done to group the contiguous set of results at the top, then it breaks to show you your result, and then breaks again to indicate that there are a lot more words not being shown right now. We’d love if we could see all of this without having to run the preview.
— 22:22
The way the leaderboard results domain loads data to display in this table is via an environment endpoint called loadResults , and currently we have hard coded a bunch of implementations for this dependency. For example, in the vocab leaderboard preview we have this to stub out a bunch of words: Effect( value: .init( outOf: 1000, results: ([1, 2, 3, 4, 5, 6, 7, 7, 15]).map { index in ResultEnvelope.Result( denseRank: index, id: UUID(), isYourScore: index == 15, rank: index, score: 6000 - index * 300, subtitle: "mbrandonw", title: "Longword\(index)" ) } ) )
— 22:46
It is a fully synchronous effect that immediately emits some mock data to feed back into the system.
— 22:55
However, just below this we see the real culprit of the preview not loading immediately: mainQueue: DispatchQueue.main.eraseToAnyScheduler()
— 23:01
This mainQueue is used in order to make sure that the loadResults effect emits back onto the main queue. That is causing our preview to incur a small thread hop, which is just enough to prevent us from getting our data into the view in time for previewing.
— 23:21
Well, luckily this is easy to fix. We just gotta swap out the live DispatchQueue for the immediate version: // mainQueue: DispatchQueue.main.eraseToAnyScheduler() mainQueue: .immediate
— 23:28
Now our preview loads immediately even when the preview isn’t running. We can make changes to the styles or view hierarchy and we will be able to instantly see what changed.
— 23:42
Let’s replace the rest of our live queues with the immediate scheduler. The next preview is the one that shows daily challenge results, and swapping out its scheduler is easy enough.
— 23:58
Strangely the preview doesn’t seem to have updated. It’s still showing the loading state. What gives?
— 24:12
This is actually a good thing. This preview is specifically designed to show off the loading state of the UI, and does so by delaying the result of the loadResults effect: Effect( value: .init( outOf: 1000, results: (1...5).map { index in ResultEnvelope.Result( denseRank: index, id: UUID(), isYourScore: index == 3, rank: index, score: 6000 - index * 800, title: "Player \(index)" ) } ) ) .delay(for: 1, scheduler: DispatchQueue.main.animation()) .eraseToEffect()
— 24:31
So it’s ok that this is showing the loading state because that’s actually what we want here.
— 24:46
Let’s move onto the third and final leaderboard preview. This one shows the results for solo games, but it’s secondary purpose is to show what happens when the loadResults endpoint fails: loadResults: { _, _ in .init(error: .init(error: NSError(domain: "", code: 1))) },
— 24:57
Swapping out the live dispatch queue for the immediate one we will see that we still have some work left to do on this code path. Currently we are just showing an empty screen, but we should show an error message and maybe a retry button. However, we’ll leave that for another time. Better isowords tests
— 25:24
So this is pretty great. We are getting real-world benefits in the isowords code base by using immediate schedulers. Despite being originally developed for testing, immediate schedulers have proved to be just as valuable for previews.
— 26:00
Let’s check out another part of the isowords code base that gets massive benefits from our new dependency techniques, and that is the daily challenge screen, which you can drill down to from the home screen. On this screen you can see the two challenges that are available to play right now, and if you’ve already played these games it will show you your rank.
— 26:36
There’s actually some complex functionality packed into this screen even though it may not see like it. First of all, tapping on one of these games buttons first makes an API request to the server to say that we want to play this game, which then the server records so that we can track which games the user has started. We use this information to prevent players from submitting multiple scores to the same puzzle, or cheating in other ways. So, only once we get a response from the server do we actually launch into the game.
— 27:02
Further, it’s possible to start an unlimited daily challenge and then come back to it later, so this screen also interacts with data saved to disk to see if there is a resumable game.
— 27:11
And further, this screen also detects if you haven’t enabled notifications for the app yet so that it can show a little button in the top right prompting you to enable.
— 27:23
So, there’s quite a bit of functionality, and sadly we don’t have any tests. So let’s write some tests and see how our new failing and immediate dependencies can assist us.
— 27:41
Let’s pretend for a moment that we aren’t super familiar with this part of the code base, and let’s see how we can take small steps in the dark to try to get a passing test. We can try constructing a TestStore for the domain we want to test, which is the dailyChallengeReducer : func testBasics() { let store = TestStore( initialState: <#_#>, reducer: dailyChallengeReducer, environment: <#_#> ) }
— 28:28
The initialState can be constructed easily because all of the state struct’s fields have default values: let store = TestStore( initialState: DailyChallengeState(), reducer: dailyChallengeReducer, environment: <#_#> )
— 28:39
The environment takes a bit more work because it has a bunch of properties that need to be filled out: let store = TestStore( initialState: .init(), reducer: dailyChallengeReducer, environment: DailyChallengeEnvironment( apiClient: <#ApiClient#>, fileClient: <#FileClient#>, mainQueue: <#AnySchedulerOf<DispatchQueue>#>, mainRunLoop: <#AnySchedulerOf<RunLoop>#>, remoteNotifications: <#RemoteNotificationsClient#>, userNotifications: <#UserNotificationClient#> ) )
— 28:52
Already this is pretty cool. Without knowing much about this feature I can tell it involves making API requests, interacting with the disk, needs schedulers for performing asynchronous work, and interacts with notifications in some way. I’m sure not every aspect of this feature uses all dependencies at once, so it will be interesting to discover the bare minimum of dependencies necessary to write a test.
— 29:13
We could start by filling in failing versions of all these dependencies. Luckily we already have failing dependencies, and so it’s straightforward to provide them: let store = TestStore( initialState: .init(), reducer: dailyChallengeReducer, environment: DailyChallengeEnvironment( apiClient: .failing, fileClient: .failing, mainQueue: .failing, mainRunLoop: .failing, remoteNotifications: .failing, userNotifications: .failing ) )
— 29:40
I have a feeling we’re going to need to construct this failing environment pretty often, so let’s go ahead and extract it into its own failing static: extension DailyChallengeEnvironment { static let failing = Self( apiClient: .failing, fileClient: .failing, mainQueue: .failing, mainRunLoop: .failing, remoteNotifications: .failing, userNotifications: .failing ) }
— 30:03
And now we can just do: let store = TestStore( initialState: .init(), reducer: dailyChallengeReducer, environment: .failing )
— 30:08
Now that the test is set up, what should we actually assert on? Well, what actions can we send? We can autocomplete the .send on store to see our options: store.send(.) // .dailyChallengeResults // .delegate // .dismissAlert // .fetchTodaysDailyChallengeResponse // .gameButtonTapped // .onAppear // .notificationButtonTapped // .notificationsAuthAlert // .setNavigation // .startDailyChallengeResponse // .userNotificationSettingsResponse
— 30:20
Lots of fun stuff to test in here, but perhaps the most obvious thing we could try to capture is what happens when the view appears. I imagine it will involve loading some data or something, but there’s only one way to be sure: store.send(.onAppear)
— 30:41
And running tests we get 4 failures: An effect returned for this action is still running. It must complete before the end of the test. … ApiClient.apiRequest(dailyChallenge(ServerRoutes.ServerRoute.Api.Route.DailyChallenge.today(language: SharedModels.Language.en))) is unimplemented - A failing effect ran. RunLoop - A failing scheduler scheduled an action to run immediately. UserNotificationClient.getNotificationSettings is not implemented - A failing effect ran.
— 30:50
So that’s a lot, but it’s amazing how we instantly see which of our dependencies is being used in this test. We see the following:
— 31:01
The .onAppear action caused an effect to be started and it has not yet finished. That is not surprising since the .failing scheduler never executes its working, and so that’s probably the culprit of the in-flight effect.
— 31:13
The second failure is coming from our API client. It tells us the exact API endpoint that was accessed. The message is kind of long, and we should probably shorten the description a bit, but essentially the route being accessed is the following: ApiClient.apiRequest(.dailyChallenge(.today(en: .en)))
— 31:28
This is the API endpoint that fetches the results for today’s daily challenge, for a particular language. Sometime soon we hope to launch isowords for multiple languages, and so that’s what this parameter is for. The response of this request is what is used to populate the UI. It shows your current rank if you have played, as well as the number of people have have played so far.
— 31:40
The next failure is because we are scheduling some work, and it even helpfully tells us that it’s the run loop being accessed. And now it’s not surprising that the .onAppear effect is still running because apparently it’s using this failing scheduler which never executes any work.
— 31:59
And the final error tells us that apparently when the view appears we fetch the user’s notification settings. This is done because it’s what decides whether or not to show the little bell icon in the top-right that hopefully entices players to turn on notifications.
— 32:11
So, these are the 3 dependencies we have to plug up to hopefully get a passing test out of this. Let’s start with the simplest. We can provide an immediate scheduler for the mainRunLoop scheduler. To do this we will define a mutable environment outside of the TestStore so that we can make changes to it: var environment = DailyChallengeEnvironment.failing environment.mainRunLoop = .immediate let store = TestStore( initialState: .init(), reducer: dailyChallengeReducer, environment: environment )
— 32:41
And that alone should reduce our test failures. If we run again we will see we are down to two failures: ApiClient.apiRequest(dailyChallenge(ServerRoutes.ServerRoute.Api.Route.DailyChallenge.today(language: SharedModels.Language.en))) is unimplemented - A failing effect ran. UserNotificationClient.getNotificationSettings is not implemented - A failing effect ran.
— 32:50
Both the scheduler failure and the in-flight effect failure have been fixed. This is because we are now supplying an actual, non-failing scheduler which executes its work.
— 32:59
Next let’s try to fix this API failure. We need to supply an API client that implements this endpoint. We are going to have a lot to say about API clients in the future on Point-Free, especially when we dive into the topic of invertible parsing, but for now suffice it to say that our API client is implemented as a kind of parser of URLRequest s into a large enum that describes every route on our website: public enum ServerRoute: Equatable { case api(Api) case appSiteAssociation case appStore case authenticate(AuthenticateRequest) case demo(Demo) case download case home case pressKit case privacyPolicy case sharedGame(SharedGame) … }
— 33:29
What we can do in our tests is take our currently failing API client and override a single endpoint to return some real data. In particular we want to override the .dailyChallenge(.today(language:)) endpoint. This can be done by calling a test helper method we have defined on API client that allows you to specify an exact route that you want to supply the response for: environment.apiClient.override( route: <#ServerRoute.Api.Route#>, withResponse: <#Effect<(data: Data, response: URLResponse), URLError>#> )
— 33:47
We can start by filling in the route we want to override: environment.apiClient.override( route: .dailyChallenge(.today(language: .en)), withResponse: <#Effect<(data: Data, response: URLResponse), URLError>#> )
— 33:56
Then we need to fill in the response. This is where we get to craft a response from scratch that allows us to test any happy paths or edge cases we want. We could return a successful response with some perfectly fine JSON, or we could respond with a failure.
— 34:10
Let’s focus on the happy path, so we need an effect that emits JSON data that is in the shape our feature expects. For this I do need to rely on a little bit of knowledge of the code base to know what kind of data this API endpoint returns, but I could also just as easily consult the server code to see what it returns. This endpoint expects an array of FetchTodaysDailyChallengeResponse values to be returned, and luckily we have a mock version of this value at hand so it’s easy to construct. We can stick it into the response of the API by using an Effect helper vended by the API that automates constructing responses from Codable values: environment.apiClient.override( route: .dailyChallenge(.today(language: .en)), withResponse: .ok([FetchTodaysDailyChallengeResponse.played]) )
— 34:54
So it took a little work, but this is packing a huge punch in just a few lines of code. This is altering our currently failing API client so that it returns an actual response, but only for the one single route we have specified here. Any other route will continue to fail, as we will see in a moment.
— 35:11
Let’s run tests and see if we have made any progress on getting a passing test suite: The store received 1 unexpected action after this one: … Unhandled actions: [ DailyChallengeAction.fetchTodaysDailyChallengeResponse( Result<Array<FetchTodaysDailyChallengeResponse>, ApiError>.success( [ FetchTodaysDailyChallengeResponse( dailyChallenge: DailyChallenge( endsAt: 2060-05-07T01:49:19Z, gameMode: GameMode.unlimited, id: Tagged<DailyChallenge, UUID>( rawValue: DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF ), language: Language.en ), yourResult: DailyChallengeResult( outOf: 1000, rank: 20, score: 3000, started: true ) ), ] ) ), ] Error: UserNotificationClient.getNotificationSettings is not implemented - A failing effect ran.
— 35:17
OK we still have two failures, but the first one is a little different. It’s saying that due to how our effect executed, it fed data back into the system that we haven’t explicitly asserted on, and in the Composable Architecture this means it’s a test failure. This is really powerful because it forces you to be exhaustive with how you handle effects.
— 35:36
So, we have to explicitly tell the store we expect an action to have been received due to our effect executing. We can lean on auto complete to try to figure out which action we expect to be fed back into the system: store.receive(.) // .dailyChallengeResults // .delegate // .dismissAlert // .fetchTodaysDailyChallengeResponse // .gameButtonTapped // .onAppear // .notificationButtonTapped // .notificationsAuthAlert // .setNavigation // .startDailyChallengeResponse // .userNotificationSettingsResponse
— 35:56
But we can also consult the failure message and confirm that we received .fetchTodaysDailyChallengeResponse , after all we are fetching today’s daily challenge results: store.receive(.fetchTodaysDailyChallengeResponse(.)) // .failure // .success
— 36:10
Then from these choices we expect to receive a successful result since our API response was an .ok : store.receive(.fetchTodaysDailyChallengeResponse(.success(???)))
— 36:15
And now we just need to provide the successful data, which was the array of the .played daily challenge response that we used in our API: store.receive(.fetchTodaysDailyChallengeResponse(.success([.played])))
— 36:22
This is now compiling, so let’s run tests to see how things changed.
— 36:28
We still get two failures, but now one of them is on the .receive line: State change does not match expectation: … DailyChallengeState( alert: nil, dailyChallenges: [ + FetchTodaysDailyChallengeResponse( + dailyChallenge: DailyChallenge( + endsAt: 2060-05-07T01:55:33Z, + gameMode: GameMode.unlimited, + id: Tagged<DailyChallenge, UUID>( + rawValue: DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF + ), + language: Language.en + ), + yourResult: DailyChallengeResult( + outOf: 1000, + rank: 20, + score: 3000, + started: true + ) + ), ], gameModeIsLoading: nil, inProgressDailyChallengeUnlimited: nil, route: nil, notificationsAuthAlert: nil, userNotificationSettings: nil ) (Expected: −, Actual: +)
— 36:39
This is happening because when the new action was fed back into the system it caused state to change, and we haven’t described what those changes were. All the changes is that we assign a dailyChallenges array in our state with the response data, which we can capture in the assertion by opening up a trailing closure and making the exact mutation to the state we expect: store.receive(.fetchTodaysDailyChallengeResponse(.success([.played]))) { $0.dailyChallenges = [.played] }
— 37:12
Now when we run tests we are down to a single failure, and it’s that failing dependency where we access the getNotificationSettings endpoint on the UserNotificationsClient dependency.
— 37:20
This dependency is our own little wrapper around the UserNotifications framework that ships with iOS. It gives us access to much of the functionality in UNUserNotificationCenter , such as adding notification requests, requesting authorization, and more. Its design is very much inspired by the techniques we described in our series of episodes titled “ Designing Dependencies ”, which is also where the weather app demo came from, so if you haven’t watched those episodes yet we highly recommend you do. In fact we even walked through the process of designing this particular dependency in our series on “ Concise Forms ”
— 37:56
The failure is being very helpful right now by pointing to exactly which endpoint is being invoked by the reducer: getNotificationSettings .
— 38:04
The getNotificationSettings endpoint is an effect that emits settings values: public struct UserNotificationClient { … public var getNotificationSettings: Effect< Notification.Settings, Never > … }
— 38:14
And the live implementation simply calls out to UNUserNotificationCenter : getNotificationSettings: .future { callback in UNUserNotificationCenter.current() .getNotificationSettings { settings in callback(.success(.init(rawValue: settings))) } },
— 38:27
Nothing too fancy there.
— 38:28
But the cool thing about wrapping this dependency in our own type is that we can now take control over it rather than let it take control over us. In particular, we can take the currently failing dependency and override just the .getNotificationSettings endpoint to return a settings value that says we are authorized: environment.userNotifications.getNotificationSettings = .init( value: .init(authorizationStatus: .authorized) )
— 39:00
This should fix the dependency failure we have, and we could certainly run tests to see what fails next, but let’s try to be a little more proactive. Clearly our reducer is executing this effect, and ostensibly it will feed the data back into the system, so most likely we need to receive an action: store.receive(.) // .dailyChallengeResults // .delegate // .dismissAlert // .fetchTodaysDailyChallengeResponse // .gameButtonTapped // .onAppear // .notificationButtonTapped // .notificationsAuthAlert // .setNavigation // .startDailyChallengeResponse // .userNotificationSettingsResponse
— 39:19
The .userNotificationSettingsResponse seems like a good bet for receiving the settings: store.receive(.userNotificationSettingsResponse(<#???#>))
— 39:27
This takes an actual Notification.Settings value, which is the .authorized value we stubbed into our getNotificationSettings endpoint: store.receive( .userNotificationSettingsResponse( .init(authorizationStatus: .authorized) ) )
— 39:36
And do we expect this to perform any mutations to state? Well, I’m guessing that the reducer saves this response in state somehow and then the UI uses that information to determine if the bell icon is displayed.
— 39:46
So, let’s open a closure, and then we can use autocomplete on $0 to figure out what state might change: store.receive( .userNotificationSettingsResponse( .init(authorizationStatus: .authorized) ) ) { $0.<#⎋#> } // alert // dailyChallenges // gameModeIsLoading // inProgressDailyChallengeUnlimited // route // notificationsAuthAlert // userNotificationSettings
— 39:51
Looks like state is holding onto userNotificationSettings , so that seems like a good bet to change: $0.userNotificationSettings = .init(authorizationStatus: .authorized)
— 40:02
There may be other things going on in this reducer, but this at least covers the obvious effects, actions and state mutations. So let’s run tests… Test Suite 'All tests' passed. Executed 12 tests, with 0 failures (0 unexpected) in 2.033 (2.040) seconds
— 40:15
Well, that worked out nicely!
— 40:16
We have written a completely new test without ever even looking at the implementation of the reducer. The failing dependencies naturally led us to what endpoints we needed to override to provide real data, and then our knowledge of how the feature is supposed to work let us to figure out how effects were executed and how state was mutated. This is honestly pretty incredible and almost feels like a magical super power.
— 40:37
It’s also incredible that the test case we crafted here really has the absolute minimum number of dependencies implemented in order for this feature to pass. If we comment out any of these we will get a failing test: var environment = DailyChallengeEnvironment.failing environment.apiClient.override( route: .dailyChallenge(.today(language: .en)), withResponse: .ok([FetchTodaysDailyChallengeResponse.played]) ) environment.mainRunLoop = RunLoop.immediateScheduler .eraseToAnyScheduler() environment.userNotifications.getNotificationSettings = .init( value: .init(authorizationStatus: .authorized) )
— 40:50
And even better, if we call out to other dependencies in our feature it will also fail. For example, suppose we did something silly, like when the view appears we fire off an API request to fetch the currently authenticated player’s information. We could even do it as a simple fire-and-forget effect so that we don’t even feed its output back into the system: case .onAppear: return .merge( environment.apiClient.apiRequest(route: .currentPlayer) .fireAndForget(), … )
— 41:21
We of course wouldn’t want this, but if we did sneak this in we would hope our test suite catches us in our shenanigans.
— 41:28
And indeed, if we run tests we instantly get caught on the line where we send the .onAppear action: ApiClient.apiRequest(currentPlayer) is unimplemented - A failing effect ran.
— 41:35
So our tests will catch us anytime we introduce new complexity into our features, whether it be new state mutations, new effects feeding data back into the system, or accessing new dependencies.
— 41:54
Let’s get back to a passing state by commenting out that current player request: // environment.apiClient.apiRequest(.currentPlayer).fireAndForget(), Even better bonus: no-op dependencies
— 42:05
So we think this all pretty incredible. Our failing dependencies act as a flashlight in a dark room. We can slowly uncover more and more of how our feature works by simply iterating on a unit test until it passes.
— 42:18
And so we think we’ve shown some really great real world uses for immediate schedulers and failing dependencies. They were powerful tools that helped us wrangle in complexity and make the most of what SwiftUI gives us.
— 42:31
But we want to cover just one more thing before ending the episode. We’ve already covered a lot, but there’s one more technique that we’d love to show everyone that can not only make tests easier to write, but also make previews simple to construct, and can even be used in product code to sandbox a feature.
— 42:55
It’s the idea of a “no-op” dependency. That is, a dependency whose endpoints are stubbed out with non-functioning logic. So, if an endpoint returns an effect, it will use the .none effect which will just complete immediately and not emit anything. Let’s take a look at why this can be handy.
— 43:12
Let’s start by going to the SwiftUI previews for the daily challenge feature. We just wrote a test for the feature, but we haven’t yet looked at its previews.
— 43:21
What we will find is a DailyChallengeView being constructed by being passed a Store , and in order to construct the Store we construct a DailyChallengeEnvironment : Preview { NavigationView { DailyChallengeView( store: .init( initialState: .init( inProgressDailyChallengeUnlimited: update(.mock) { $0?.moves = [.highScoringMove] } ), reducer: dailyChallengeReducer, environment: .init( apiClient: .noop, fileClient: .noop, mainQueue: .immediate, mainRunLoop: .immediate, remoteNotifications: .noop, userNotifications: .noop ) ) ) } }
— 43:31
We can see that we are already using immediate schedulers everywhere, and so that’s nice, but there are also all of these .noop dependencies. We can jump to the definition of one of these, say the API client: extension ApiClient { public static let noop = Self( apiRequest: { _ in .none }, authenticate: { _ in .none }, baseUrl: { URL(string: "/")! }, currentPlayer: { nil }, logout: { .none }, refreshCurrentPlayer: { .none }, request: { _ in .none }, setBaseUrl: { _ in .none } ) }
— 43:48
What we see here is an implementation of the ApiClient interface that does the absolute bare minimum of work. If it’s a closure that returns an effect which just return the .none effect, and if it’s an endpoint that returns some type of value we just put in some kind of stub value. So baseUrl returns a URL of just a slash, and currentPlayer returns nil .
— 44:09
We can look at some of the other .noop clients too, such as the FileClient : extension FileClient { public static let noop = Self( delete: { _ in .none }, load: { _ in .none }, save: { _, _ in .none } ) }
— 44:16
Or the RemoteNotificationsClient : extension RemoteNotificationsClient { public static let noop = Self( isRegistered: { true }, register: { .none }, unregister: { .none } ) }
— 44:27
And the UserNotificationClient : extension UserNotificationClient { public static let noop = Self( add: { _ in .none }, delegate: .none, getNotificationSettings: .none, removeDeliveredNotificationsWithIdentifiers: { _ in .none }, removePendingNotificationRequestsWithIdentifiers: { _ in .none }, requestAuthorization: { _ in .none } ) } All of them implement their interfaces by doing essentially nothing, hence the name .noop .
— 44:32
These kinds of interfaces are perfect for plugging into SwiftUI previews where we don’t actually need their functionality. For example, in this preview we have no need for having the functionality of registering for remote notifications, and so a .noop RemoteNotificationsClient is perfectly fine.
— 44:56
If there is a dependency endpoint we want to implement, then we can simply pull the environment out into a mutable variable and override the closures you want.
— 45:07
For example, we could override the getNotificationSettings endpoint to fake us being in the .notDetermined state for notifications: var environment = DailyChallengeEnvironment( apiClient: .noop, fileClient: .noop, mainQueue: .immediate, mainRunLoop: .immediate, remoteNotifications: .noop, userNotifications: .noop ) environment.userNotifications.getNotificationSettings = .init( value: .init(authorizationStatus: .notDetermined) ) return Preview { NavigationView { DailyChallengeView( store: .init( initialState: .init( inProgressDailyChallengeUnlimited: update(.mock) { $0?.moves = [.highScoringMove] } ), reducer: dailyChallengeReducer, environment: environment ) ) } }
— 45:35
And now the preview shows the little bell icon in the top right.
— 45:57
But .noop dependencies aren’t only handy for previews. They can also be handy for quieting certain dependencies in tests. To see an example of this, let’s switch to the GameOverFeature module and open up GameOverFeatureTests.swift .
— 46:14
In this test target there are a bunch of places we construct a failing GameOverEnvironment to set as the basis for our test, and then we immediately set its audioPlayer to .noop : var environment = GameOverEnvironment.failing environment.audioPlayer = .noop
— 46:31
This is because music is played right at the entry point for the game over screen, and if we didn’t turn this into a .noop dependency we would have to assert on its behavior for every test.
— 46:44
Now this does mean we may miss out on some future test coverage if we sprinkle in more audio player code, but it’s a tradeoff we’re willing to take to keep the rest of the test suite short, and we feel it definitely has its uses in real world code bases.
— 47:10
Let’s look at one last example of how .noop dependencies can be handy, this time in actual production code that ships to the App Store.
— 47:21
The first time you open isowords you are presented with an onboarding experience to teach you how to play the game. We built this experience using techniques we developed on our series of episodes discussing SwiftUI redactions . In those episodes we showed how SwiftUI provides us some really powerful APIs for redacting views, but gives no story on how to redact logic in your screens. So, we picked up where SwiftUI left off and showed that the Composable Architecture gives us a natural place to limit functionality of a feature, or even layer on additional functionality, all without needing to add any new code to the underlying, core feature.
— 48:17
This was really powerful. In the past episodes it allowed us to build an onboarding experience for a todo app, where we could have the user walk through various pieces of functionality in the app, step by step. At each step of the way we got to decide which parts of the app were functional, and which parts were inert.
— 48:32
We employed this same technique to build the onboarding experience for isowords. In particular, when embed the domain of the core game feature into our onboarding feature, and at each step of the way we decide how to limit or layer onto the game’s functionality.
— 49:04
However, in order to embed the game’s domain into onboarding’s domain we have to provide all of the game’s dependencies, and there are a lot: public struct GameEnvironment { public var apiClient: ApiClient public var applicationClient: UIApplicationClient public var audioPlayer: AudioPlayerClient public var backgroundQueue: AnySchedulerOf<DispatchQueue> public var build: Build public var database: LocalDatabaseClient public var dictionary: DictionaryClient public var feedbackGenerator: FeedbackGeneratorClient public var fileClient: FileClient public var gameCenter: GameCenterClient public var lowPowerMode: LowPowerModeClient public var mainQueue: AnySchedulerOf<DispatchQueue> public var mainRunLoop: AnySchedulerOf<RunLoop> public var remoteNotifications: RemoteNotificationsClient public var serverConfig: ServerConfigClient public var setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect<Never, Never> public var storeKit: StoreKitClient public var userDefaults: UserDefaultsClient public var userNotifications: UserNotificationClient … }
— 49:23
This is to be expected because the game feature is quite complex. However, the onboarding experience does not need most of the dependencies. We are just wanting to show the cube on the screen, allow user interaction with it, play sound effects, and things like that. We certainly don’t need to interact with the API server, or need a database, or need Game Center, and really need most of these things.
— 49:50
Well, the .noop dependency technique gives us the perfect way to put in non-functioning dependencies for the things we don’t need. The way we do this is to create the OnboardingEnvironment to hold all the dependencies we actually do need, like the audio player, haptic feedback generator, dictionary and more: public struct OnboardingEnvironment { var audioPlayer: AudioPlayerClient var backgroundQueue: AnySchedulerOf<DispatchQueue> var dictionary: DictionaryClient var feedbackGenerator: FeedbackGeneratorClient var lowPowerMode: LowPowerModeClient var mainQueue: AnySchedulerOf<DispatchQueue> var mainRunLoop: AnySchedulerOf<RunLoop> var userDefaults: UserDefaultsClient }
— 50:06
And then we define a computed property that will create a GameEnvironment from our OnboardingEnvironment , where we substitute in .noop for any dependency we don’t have at hand: var gameEnvironment: GameEnvironment { GameEnvironment( apiClient: .noop, applicationClient: .noop, audioPlayer: self.audioPlayer, backgroundQueue: self.backgroundQueue, build: .noop, database: .noop, dictionary: self.dictionary, feedbackGenerator: self.feedbackGenerator, fileClient: .noop, gameCenter: .noop, lowPowerMode: self.lowPowerMode, mainQueue: self.mainQueue, mainRunLoop: self.mainRunLoop, remoteNotifications: .noop, serverConfig: .noop, setUserInterfaceStyle: { _ in .none }, storeKit: .noop, userDefaults: self.userDefaults, userNotifications: .noop ) }
— 50:30
This makes it possible to run the game feature inside the onboarding feature as it is being provided all of its dependencies, it just doesn’t know that most of them are .noop versions.
— 50:39
So, this is a real world use case of how you might want to use .noop dependencies in actual production code, not just tests or previews. It allows you to create a little sandbox environment to run a version of the feature in some other context. Conclusion
— 50:55
And that concludes our series of episodes on better test dependencies.
— 50:59
We showed that failing dependencies allow us to be exhaustive in what dependencies are absolutely necessary to test a particular slice of a feature’s functionality. This came with a ton of benefits, such as being able to add new functionality to your feature and being instantly notified of what tests need to be updated, and being able to write a test from scratch while the failures guide you to what needs to be implemented.
— 51:23
Then we showed that while test schedulers are an important tool for controlling time in a test, there’s sometimes a simpler tool we can use. Immediate schedulers are handy for when you don’t mind squashing all of time into a single instant. It helps clean up noise from your tests, and it surprisingly even helped make our SwiftUI previews stronger. We can now have our previews exercising more of our view’s logic so that what we see in the preview is a more realistic representation of what we would see on a device.
— 51:56
And then finally we through in a little bit of bonus material by discussing .noop dependencies. These type of dependency implementation hit the trifecta of usefulness: you can use it in tests, SwiftUI previews and even production code. It’s the perfect dependency to plug in for when you don’t want of its logic to be executed in your feature.
— 52:16
So, that’s a lot of material, and we’re sorry this episode ran so long but we just couldn’t help ourselves. References isowords Point-Free A word game by us, written in the Composable Architecture. https://www.isowords.xyz isowords on GitHub Point-Free • Apr 17, 2021 Open source game built in SwiftUI and the Composable Architecture. https://github.com/pointfreeco/isowords 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 0141-better-test-dependencies-pt4 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 .