EP 207 · Reducer Protocol · Oct 3, 2022 ·Members

Video #207: Reducer Protocol: Testing

smart_display

Loading stream…

Video #207: Reducer Protocol: Testing

Episode: Video #207 Date: Oct 3, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep207-reducer-protocol-testing

Episode thumbnail

Description

Testing is a top priority in the Composable Architecture, so what does the reducer protocol and new dependency management system add to testing features? It allows us to codify a testing pattern directly into the library that makes our tests instantly stronger and more exhaustive.

Video

Cloudflare Stream video ID: bd860e6b8169b402676e2d31b66f7c2b Local file: video_207_reducer-protocol-testing.mp4 *(download with --video 207)*

Transcript

0:05

So this is pretty incredible.

0:06

We have vastly overhauled how dependencies are handled in the library, as long as you are using the new ReducerProtocol style of building your features. You can declare that a reducer wants a dependency by just using the @Dependency property wrapper, and you don’t even have to explicitly pass that dependency when constructing the reducer. It is automatically provided to the reducer behind the scenes.

0:26

This means you no longer need to provide an initializer for your reducers or environment just to pass dependencies from the parent layer down to the child, and you can even add, remove or change dependencies in a deep, leaf node of your application, and you won’t have to make a single change in any of the parent layers.

0:43

Further, it is also possible to make tweaks to the dependencies a reducer uses from the outside. We saw this a moment ago in previews where we wanted to preview our feature in a more controlled environment, one that did not actually interact with AVFoundation. But you can apply this idea also to running a portion of your application in a different execution environment. For example, if you have an onboarding experience for teaching people how to use a feature, you can run that feature with altered dependencies so that they don’t interact with the real world, like save data to disk, make database requests, or hit the network.

1:18

That allows you to run your feature in a kind of sandbox, so that you can fully control what your user experiences during onboarding.

1:24

So, this all seems great, but can you believe it even gets better?

1:28

We’ve now had multiple episodes about reducer protocols and we haven’t talked about testing once. Testing is a top priority for the library. We never want to make changes or introduce features that can hurt testability. It turns out that our move towards reducer protocols and this new style of dependencies only improves the testing story for the library. It allows us to codify a pattern directly into the library that makes your tests stronger and more exhaustive. Test store dependencies

1:59

The voice memos demo we have been working with has an extensive test suite .

2:04

It has 8 tests exercising all of the major flows of the application, including both happy and unhappy paths.

2:11

Every single test follows the same pattern. First we construct a test store with some initial state, the reducer we are testing, as well as something called “unimplemented” for the environment: let store = TestStore( initialState: VoiceMemosState(), reducer: voiceMemosReducer, environment: .unimplemented )

2:21

The “unimplemented” environment is a value of the VoiceMemosEnvironment that has every endpoint stubbed in such a fashion that if you invoke any function on the dependency you get a test failure: extension VoiceMemosEnvironment { static let unimplemented = Self( audioPlayer: .unimplemented, audioRecorder: .unimplemented, mainRunLoop: .unimplemented, openSettings: XCTUnimplemented( "\(Self.self).openSettings" ), temporaryDirectory: XCTUnimplemented( "\(Self.self).temporaryDirectory", placeholder: URL( fileURLWithPath: NSTemporaryDirectory() ) ), uuid: XCTUnimplemented( "\(Self.self).uuid", placeholder: UUID() ) ) }

2:37

For example, the openSettings function is a void-to-void async function, and here we have stubbed it with a function called XCTUnimplemented : openSettings: XCTUnimplemented( "\(Self.self).openSettings" ),

2:39

This is a helper from our XCTest Dynamic Overlay library that allows you to construct a function of any form, but secretly under the hood if you ever invoke it it just performs an XCTFail causing the test to fail.

2:51

We also have “unimplemented” versions of the audio player, audio recorder and main run loop scheduler, and all of them do the same. If you ever invoke an endpoint on any of those dependencies you will instantly get a test failure.

3:01

This allows you to write tests that not only prove your features logic is working as expected, but you can further prove that certain dependencies aren’t even touched.

3:08

For example, in the testDeleteMemoWhilePlaying test we exercise what happens when you delete a memo while it is playing. This is an important test to have because we want to make sure any long-living effects are torn down, such as the timer that drives the progress bar.

3:24

However, this test does not need access to the entire environment of dependencies. The only things we expect to be used are the audio player and the main run loop, for the timer. In particular, we don’t expect the recorder to be used, or the temporary directory, or even the UUID generator. We can express this in our test by starting the test store with an unimplemented environment, and then just override the few dependencies we expect to be used: let store = TestStore( initialState: VoiceMemosState(…), reducer: voiceMemosReducer, environment: .unimplemented ) store.environment.audioPlayer.play = { _ in try await Task.never() } store.environment.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler()

3:49

The fact that this test passes, or at least did before our gigantic ReducerProtocol refactor, proves that none of the other dependencies are ever touched during the flow of the user playing a memo and then deleting it while it is playing.

4:00

Then, in the future if we start making use of a new dependency during this execution flow we will be instantly notified of it in our tests because it will raise a test failure. This makes our tests much stronger in what they are actually testing, and can be incredibly powerful.

4:15

After the test store is constructed with an unimplemented environment and its dependencies are overridden, we then go through the process of actually simulating a script of user actions and asserting on how state changes and how effects execute and feed their data back in the system: await store.send( .voiceMemo(id: url, action: .playButtonTapped) ) { $0.voiceMemos[id: url]?.mode = .playing(progress: 0) } await store.send(.voiceMemo(id: url, action: .delete)) { $0.voiceMemos = [] }

4:42

Let’s see what it takes to get these tests building and passing again. We’ll comment out all the tests except for this last one so that we have something more manageable to work on.

4:54

…and let’s comment out the VoiceMemosEnvironment extension at the bottom of the file since that no longer exists.

5:01

First we can update the test store so that it takes the VoiceMemos reducer type, but test stores don’t yet understand how to deal with ReducerProtocol values, and so we need to wrap it with the Reducer initializer we defined before: let store = TestStore( initialState: VoiceMemos.State(…), reducer: Reducer(VoiceMemos()), environment: () )

5:17

…and we no longer deal with environments so we can just stub in a Void environment.

5:22

Next we need to override all of the dependencies on the VoiceMemos reducer. Previously we were able to reach directly into the test store and mutate its environment, but now there is no actual environment to mutate. One thing we could do is just repeatedly apply the .dependency override method on the VoiceMemos value to set the dependencies: let store = TestStore( initialState: VoiceMemos.State(…), reducer: Reducer( VoiceMemos() .dependency(\.audioPlayer.play) { _ in try await Task.never() } .dependency( \.mainRunLoop, self.mainRunLoop.eraseToAnyScheduler() ) ), environment: () )

5:57

And just like that the test is now building and it even passes.

6:06

But there are a few things wrong with this. First, this is not a very ergonomic way to override dependencies for tests. Sometimes the body of a mocked out dependency endpoint can be a few lines, such as the stopRecording endpoint in our first test: store.environment.audioRecorder.stopRecording = { didFinish.continuation.yield(true) didFinish.continuation.finish() }

6:22

It will get quite messy to have all of this work being done in a massive chain of dependency calls.

6:27

What if we could go back to the old style of mutating the dependencies directly on the test store, but rather than reaching into store.environment maybe we should call it store.dependencies : let store = TestStore( initialState: VoiceMemos.State(…), reducer: Reducer(VoiceMemos()), environment: () ) store.dependencies.audioPlayer.play = { _ in try await Task.never() } store.dependencies.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler()

6:43

Let’s see what it takes to enable this kind of dependency management in tests. We can start by adding a mutable dependencies variable to the TestStore that just defaults to an empty storage of dependency values: public final class TestStore< State, LocalState, Action, LocalAction, Environment > { public var dependencies = DependencyValues() … }

7:03

This will be the dependencies the reducer gets access to when run inside the test store.

7:07

In order to enforce that we just to wrap the core test store’s reducer logic in a withValue so that we can swap in the test store’s dependencies: self.store = Store( initialState: initialState, reducer: Reducer< State, TestAction, Void > { [unowned self] state, action, _ in DependencyValues.$current .withValue(self.dependencies) { … } }, environment: () )

7:34

That small change is all we need to do. It guarantees that when our feature is run inside a test store it picks up the dependencies that we specify in the test. And if we run our one test it still passes.

7:51

Let’s now quickly get all of our tests building. We can uncomment out all of the tests and then perform just a few small updates: Update references to state types to use the new nested types. Replace references of voiceMemosReducer with Reduce(VoiceMemos()) Replace the “unimplemented” environments with a Void environment and finally replace all references to store.environment with store.dependencies Unimplemented dependencies

9:00

With those changes the entire test suite is building, all tests pass, and tests basically look the same as they did before.

9:04

But there is something pretty important we lost in our conversion. In the previous code we had employed the nice pattern of starting the environment of the feature as “unimplemented” and then overrode just the few dependency endpoints we needed for the feature. This was great for proving what dependencies our feature actually needs to do its job, and means in the future if an execution flow starts using a new dependency we will be instantly notified with a test failure, and can then decide if that is a bug in our logic or if we need to update the test to account for that logic.

9:34

However, with our refactored tests we are actually starting the test store with a fully live set of dependencies, and then we override the ones we think the feature will use with mocks. That is basically the opposite of how the tests used to be, and is definitely not something we want to do. We would not want to be accidentally hitting real life dependencies during tests, such as making network requests, writing files to disk, or who knows what else.

10:00

Now one thing we could do is manually override all the dependencies to be their unimplemented versions, and then after that again override just the dependencies we think we will use: func testDeleteMemoWhilePlaying() async { let store = TestStore(…) store.dependencies.audioPlayer = .unimplemented store.dependencies.audioRecorder = .unimplemented store.dependencies.mainRunLoop = .unimplemented store.dependencies.openSettings = XCTUnimplemented() store.dependencies.temporaryDirectory = XCTUnimplemented() store.dependencies.uuid = XCTUnimplemented() store.dependencies.audioPlayer.play = { _ in try await Task.never() } store.dependencies.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() … }

10:38

We could even make this a bit nicer by providing a helper on DependencyValues that represents an “unimplemented” set of voice memos dependencies: extension DependencyValues { static var unimplementedVoiceMemos: Self { var dependencies = DependencyValues() dependencies.audioPlayer = .unimplemented dependencies.audioRecorder = .unimplemented dependencies.mainRunLoop = .unimplemented dependencies.openSettings = XCTUnimplemented() dependencies.temporaryDirectory = XCTUnimplemented() dependencies.uuid = XCTUnimplemented() return dependencies } }

11:11

And then we could use it like so: func testDeleteMemoWhilePlaying() async { let store = TestStore(…) store.dependencies = .unimplementedVoiceMemos store.dependencies.audioPlayer.play = { _ in try await Task.never() } store.dependencies.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler() … }

11:36

This looks nearly identical to how we were writing out tests previously, but it still isn’t as strong as it was before. Previously we had proof that all dependencies were mocked as unimplemented because we had a static environment type that we needed to construct, and we were forced to put in a dependency into each of its fields.

12:07

Right now we are just overriding a bunch of dependencies in the nebulous blob of storage, but we can’t be 100% sure we got all of the dependencies that the voice memos feature uses. If someday in the future VoiceMemos depends on something new, like say an analytics client, we will have to remember to come back to this unimplemented helper and add the analytics client. Previously this was statically enforced for us by the compiler.

12:34

Well, luckily this is possible to fix, and even possible to make it nicer than what the old style offered us. Right now all dependencies start with the default specified when you conform to DependencyKey , and so far all of our dependencies have used the real, live version of the dependency for the default. The run loop dependency uses the real life main run loop: private enum MainRunLoopKey: DependencyKey { static let defaultValue = AnySchedulerOf<RunLoop>.main } …the date and UUID dependencies reach out to the real initializers: private enum DateKey: DependencyKey { static let defaultValue = { @Sendable in Date() } } private enum UUIDKey: DependencyKey { static let defaultValue = { @Sendable in UUID() } } …the audio player dependency uses the live implementation of the interface: private enum AudioPlayerClientKey: DependencyKey { static let defaultValue = AudioPlayerClient.live } …and on and on.

12:59

What if we baked the notion of a “test” dependency directly into the dependency system for the library. So, when adding a new dependency you would provide both a test version of the dependency as well as a live version. Then, when features are run in tests we would default to use the test dependency, and otherwise we would use the live dependency.

13:20

This makes it possible to have our tests use “unimplemented” versions of all dependencies without having to lift a finger. We would just get it for free. And then we could further override any of the dependencies that our feature needs for the particular user flow we are testing.

13:35

Let’s see what it takes to make this happen. Let’s update the DependencyKey protocol so that in order to conform to it you must provide both a “live” and “test” implementation of that dependency: public protocol DependencyKey { associatedtype Value: Sendable static var liveValue: Value { get } static var testValue: Value { get } }

14:04

This creates a bunch of compiler errors where we are referring to defaultValue , and those all now need to be liveValue .

14:24

And just to quickly make the compiler happy let’s provide a default implementation of testValue by returning the liveValue : extension DependencyKey { public static var testValue: Value { Self.liveValue } }

14:41

We are only doing this temporarily so that we don’t have to go add test values for all of our dependencies right now. This is not something we would want to actually ship in the library because it makes it easy for people to forget to provide a test dependency.

14:58

But, caveats aside, things are now compiling, but we need to update DependencyValues so that when we ask for a dependency that doesn’t yet exist, we use the test dependencies when running in tests and the live dependency otherwise.

15:23

However, how do we know when we’re running in tests? There are a few tricks we could employ to detect this, like checking for various environment variables to see if they indicate we are running a test process, but there is far easier way. We can leverage the dependencies system we have already built.

15:38

We’ll add a new dependency to DependencyValues that is a boolean representing if we are currently running in a test: private enum IsTestingKey: DependencyKey { static let liveValue = false static let testValue = true } extension DependencyValues { var isTesting: Bool { get { self[IsTestingKey.self] } set { self[IsTestingKey.self] = newValue } } }

16:03

Then we’ll have the test store’s dependencies start in a state with isTesting true: public final class TestStore< State, LocalState, Action, LocalAction, Environment > { public var dependencies = { var dependencies = DependencyValues() dependencies.isTesting = true return dependencies }() … }

16:27

And then we can check if that value is true when retrieving a dependency so that when the dependency is not there we know whether to default to the test or live version: get { guard let dependency = self.storage[ObjectIdentifier(Key.self)] as? Key.Value else { let isTesting = self.storage[ObjectIdentifier(IsTestingKey.self)] as? Bool ?? false guard isTesting else { return Key.liveValue } return Key.testValue } return dependency }

17:03

That’s all it takes. Let’s now implement the testValue for all of our dependencies by providing unimplemented versions. For example, the main run loop and dispatch queue can be handled like so: private enum MainRunLoopKey: DependencyKey { static let liveValue = AnySchedulerOf<RunLoop>.main static let testValue = AnySchedulerOf<RunLoop>.unimplemented } private enum MainDispatchQueueKey: DependencyKey { static let liveValue = AnySchedulerOf<DispatchQueue>.main static let testValue = AnySchedulerOf<DispatchQueue>.unimplemented }

17:44

The date and UUID dependencies can be handled like so: import XCTestDynamicOverlay private enum DateKey: DependencyKey { static let liveValue = { @Sendable in Date() } static let testValue: @Sendable () -> Date = XCTUnimplemented( #"@Dependency(\.date)"#, placeholder: Date() ) } private enum UUIDKey: DependencyKey { static let liveValue = { @Sendable in UUID() } static let testValue: @Sendable () -> UUID = XCTUnimplemented( #"@Dependency(\.uuid)"#, placeholder: UUID() ) }

18:37

The temporary directory and open settings dependencies can be handled similarly: import XCTestDynamicOverlay private enum TemporaryDirectoryKey: DependencyKey { static let liveValue = { @Sendable in URL(fileURLWithPath: NSTemporaryDirectory()) } static let testValue: @Sendable () -> URL = XCTUnimplemented( #"@Dependency(\.temporaryDirectory)"#, placeholder: URL( fileURLWithPath: NSTemporaryDirectory() ) ) } private enum OpenSettingsKey: DependencyKey { static let liveValue = { @Sendable in _ = await UIApplication.shared.open( URL(string: UIApplication.openSettingsURLString)! ) } static let testValue: @Sendable () async -> Void = XCTUnimplemented(#"@Dependency(\.openSettings)"#) }

19:12

And finally, the audio player and recorder clients can be handled like so: private enum AudioPlayerClientKey: DependencyKey { static let liveValue = AudioPlayerClient.live static let testValue = AudioPlayerClient.unimplemented } private enum AudioRecorderClientKey: DependencyKey { static let liveValue = AudioRecorderClient.live static let testValue = AudioRecorderClient.unimplemented }

19:40

With those changes we can now get rid of our temporary extension to default the test value to the live value: // extension DependencyKey { // public static var testValue: Value { self.liveValue } // }

19:47

And everything still compiles and tests still pass.

19:58

But now we can completely get rid of the unimplementedVoiceMemos helper we defined for stubbing out all of the voice memo’s dependencies.

20:07

And we only need to touch the dependencies in the tests that we actually care about: // store.dependencies = .unimplementedVoiceMemos store.dependencies.audioPlayer.play = { _ in try await Task.never() } store.dependencies.mainRunLoop = self.mainRunLoop.eraseToAnyScheduler()

20:13

Further, if we forget to mock all the dependencies that the feature uses: // store.dependencies.audioPlayer.play = { _ in // try await Task.never() // } // store.dependencies.mainRunLoop = // self.mainRunLoop.eraseToAnyScheduler() …then we are no longer defaulting to using the live versions of the dependencies. This will now default to using the unimplemented versions of the dependencies, and so if we run tests we get a bunch of failures letting us know the feature is using things we didn’t mock: testDeleteMemoWhilePlaying(): RunLoop - An unimplemented scheduler scheduled an action to run on a timer. Failed: testDeleteMemoWhilePlaying(): Unimplemented: AudioPlayerClient.play … So this is pretty amazing. The pattern that we have long encouraged people to adopt where you start off your test dependencies in an unimplemented state and then only override the bare minimum of endpoints you user flow actually uses can be codified into the library itself. Your test store can now default to unimplemented dependencies basically for free, making it that much easier for you to write tests that are even stronger and more exhaustive. Modular dependencies

20:46

This is all looking great. We have shown that not only do we not lose any testability by moving towards a reducer protocol and completely changing the way we manage dependencies, but that tests became even stronger. We’ve codified a good testing pattern right into the library in order to nudge you to make your tests even stronger.

21:05

So, we could stop here, but there’s a few more improvements we can make. While testing is pretty much the top priority for the library, another priority is modularity. The library tries to provide the tools necessary to break down large features down into smaller ones that piece together. If you can do that, and if you can further put those child features into separate modules, you get the ability to quickly iterate on small pieces of your application without building the entire project. This includes running previews, running tests, and even running just a small feature in the simulator or device. It’s a really powerful way to build applications and unlocks a whole new level of productivity.

21:42

However, our new style of dependencies has hindered modularity a little bit. Let’s see what’s wrong and how to fix it.

21:50

The most obvious problem is that we have gone back to needing to depend on the Composable Architecture in order to define our dependencies interface and implementations. For example, in “AudioPlayerClient.swift” we see that we have to import ComposableArchitecture in order to get access to DependencyKey and DependencyValues , which is what allows our dependency to be usable from a reducer.

22:13

This is a step back from our goals when introducing more concurrency tools to the library. One of the positives to using native Swift concurrency is that we could have our dependencies use simple async functions and AsyncStream s rather than returning Effect values. This allowed us to build our dependencies without needing to build the Composable Architecture at all, and that means that we could share the dependency with other applications, even ones not using the Composable Architecture. Even a vanilla SwiftUI application could make use of these audio player and recorder clients in order to maintain testability.

22:43

Luckily the fix for this is easy. We will simply move all the dependency-related code to its own module that the Composable Architecture can depend on: .target( name: "Dependencies", dependencies: [ .product( name: "CombineSchedulers", package: "combine-schedulers" ), ] ), .target( name: "ComposableArchitecture", dependencies: [ "Dependencies", … ] )

23:20

We can drag-and-drop “Dependencies.swift” to this new directory.

23:33

And we can make it so that when you import ComposableArchitecture it also implicitly imports Dependencies , which will save us from having to add a bunch of imports to fix compiler errors: @_exported import Dependencies

23:53

And finally we have to make a few things public.

24:24

With that done we can now import just Dependencies for our audio player client instead of all of the ComposableArchitecture : import Dependencies import Foundation struct AudioPlayerClient { … } private enum AudioPlayerClientKey: DependencyKey { … } extension DependencyValues { … }

24:40

If we were to move this dependency into its own module or package, we would no longer have to build all of the Composable Architecture in order to build this lightweight interface and few implementations. The Dependencies module builds super quickly, nearly instantly, whereas the ComposableArchitecture library does take a few seconds to compile.

24:56

The code in the dependencies module could someday even ship as a standalone library. We have uses for this concept of dependencies beyond the Composable Architecture, and when we get around to that we will probably extract it out into its own package.

25:08

So, that’s a small win for modularity. We can now build our dependencies without having to depend on or build the Composable Architecture. But there’s another roadblock towards proper modularity in the project.

25:19

Right now the AudioPlayerClient type, which is the interface to playing an audio file, lives in the same module as its two implementations: the live implementation and the “unimplemented” implementation. Typically we prefer to have the interface of a dependency in its own module, and then the live implementation in its own module. The live implementation can depend on the interface, but critically the interface should not depend on the live implementation.

25:42

We want to do this because often the live implementation of a dependency can be quite heavy weight. None of our dependencies have heavy live implementations, but you may be creating a dependency to wrap some large, complex library, such as Firebase, or a web socket library, or a data compression library, or who knows what else.

26:00

We can even simulate this scenario by adding a line of code to LiveAudioPlayerClient.swift that taxes the compiler and causes the project to take 9 seconds longer to build: let x = String( 1 - 1 + 1.0 - 1 == 1 - 1 + 1.0 - 1 + 1.0 ? 1 + 1 + 1.0 : 1.0 - 1.0 )

26:22

We have now slowed down our ability to iterate on this feature just because the live dependency takes a long time to build.

26:29

If your interface is in a separate module, then your features can depend on just that interface, and that allows you to build, iterate, test, and even run the feature in Xcode previews, all without ever building the heavy weight 3rd party library. This greatly speeds up compile times and your ability to work on the feature in isolation, and we’ve even found that by not depending on certain heavy weight 3rd party libraries we even improve the stability of Xcode previews.

26:51

Then, the only time you actually build the 3rd party library is when you are running the full application in the simulator or on a device. That’s the only time you truly need its power. In tests and previews you can just provide some mock data and behavior.

27:03

So, all of this sounds great, but we have now ruined our ability to adopt this pattern because the DependencyKey protocol requires us to provide both the test and live implementation side-by-side: private enum AudioPlayerClientKey: DependencyKey { static let liveValue = AudioPlayerClient.live static let testValue = AudioPlayerClient.unimplemented }

27:14

This means we have to keep the interface and implementations in the same module.

27:19

Well, luckily there’s a fix. We will separate out the concepts of a test and live dependencies into two protocols: public protocol TestDependencyKey { associatedtype Value: Sendable static var testValue: Value { get } } public protocol DependencyKey: TestDependencyKey { static var liveValue: Value { get } }

27:48

This will allow us to have an interface module that contains only the lightweight interface types along with the unimplemented conformance, and in that module we can add the dependency to DependencyValues : import Dependencies import Foundation struct AudioPlayerClient { … } private enum AudioPlayerClientKey: TestDependencyKey { static let testValue = AudioPlayerClient.unimplemented } extension DependencyValues { var audioPlayer: AudioPlayerClient { get { self[AudioPlayerClientKey.self] } set { self[AudioPlayerClientKey.self] = newValue } } }

28:07

And then in a separate module where we define the heavy weight live implementation of the interface, we will further conform to DependencyKey : extension AudioPlayerClientKey: DependencyKey { static let liveValue = AudioPlayerClient.live }

28:20

This will allow us to add the dependency to the nebulous blob of dependencies storage, while also making it so that the interface and live implementations can be put into separate modules.

28:28

Now this doesn’t work yet because we only want to subscript into DependencyValues with a TestDependencyKey , and not a DependencyKey , but then that means we have no way to grab the live dependency for a default: public subscript<Key: TestDependencyKey>( key: Key.Type ) -> Key.Value { get { guard let dependency = self.storage[ObjectIdentifier(key)] as? Key.Value else { let isTesting = … guard isTesting else { return Key.liveValue } return Key.testValue } return dependency } set { … } } Type ‘Key’ has no member ‘liveValue’

28:56

We can leverage some of Swift 5.7’s new existential type features by dynamically checking if the key we are subscripting with happens to conform to the DependencyKey protocol, and if it does, take the liveValue . Otherwise we will fallback to the testValue : return (key as? any DependencyKey.Type)?.liveValue as? Key.Value ?? Key.testValue

29:36

That’s all it takes. We now have the ability to better modularize our dependencies, putting the interface in a module separate from the implementation. Conclusion

29:45

This is all it takes to make testing with the new dependency system play nicely with modularity. We can now put the interface and implementations of dependencies in separate modules so that live implementations don’t drag down our compile times while working in previews or tests, and the only time we really need to build the live implementation is when running the full app in a simulator or on a real device.

30:03

And we have now accomplished everything we set out to when we started this series many episodes ago. We have put a protocol in front of our reducer type and it has unlocked lots of amazing things:

30:14

First of all it provided a natural place for us put the domain of our features rather than having a bunch of types scattered in the file scope.

30:21

It also helped relieve some strain on the compiler that can happen when defining closures at the file scope. This helped us get back some warnings and diagnostics that were previously lost.

30:31

Then we saw that by using a protocol we were naturally led to consider what result builders could provide for us when it came to reducer composition. And it turns out a lot. We were able to completely reimagine what reducer composition looks like by taking some inspiration from SwiftUI, and it led us to more concise and safer ways of composing many features together.

30:50

And then we saw that the reducer protocol gave us a natural place to house dependencies, and again taking some inspiration from SwiftUI we were able to remove a ton of boilerplate. We no longer have to manage an environment struct, no longer have to provide an explicit initializer, and no longer have to slice up dependencies to pass down to child features. It all just works with very little additional work on our part.

31:13

And even better, we were able to accomplish all of that without hurting testing, and in fact even making testing better. There is now a natural place for us to define our unimplemented dependencies, and those are just automatically used when running our features in the test store.

31:27

So, this is all amazing, but there are even more cool things to discover. We have discovered lots of fun new features that can be added to the Composable Architecture thanks to these new tools that were previously impossible.

31:40

But before we get to that we have a few more things to discuss. Downloads Sample code 0207-reducer-protocol-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 .