Video #237: Composable Stacks: Testing
Episode: Video #237 Date: May 29, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep237-composable-stacks-testing

Description
We complete the series by writing a comprehensive test suite for our stack navigation-based app. We will uncover some shortcomings of the tools we’ve built and address each one, resulting in a set of tools that are a joy to test with.
Video
Cloudflare Stream video ID: ecede137eb7dce6900a3802036ee763f Local file: video_237_composable-stacks-testing.mp4 *(download with --video 237)*
References
- Discussions
- Clocks
- swift-dependencies
- Composable navigation beta GitHub discussion
- 0237-composable-navigation-pt16
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
It’s absolutely incredible to see, and these two tools are the fundamental ways in which we handle tree-based and stack-based navigation in Composable Architecture applications.
— 0:20
Now what would be really cool is if we could write a test that proves this works as we expect. If you remember, last time we uncovered this bug we first wrote a test to demonstrate the problem before even trying to fix it. Then we fixed the bug, saw it fixed the test, and saw that it fixed the behavior in the simulator too. That was really cool to see because it shows just how much of the behavior of our tools is unit testable, and doesn’t even need to be run in the simulator most of the time. Stephen
— 0:47
However, we haven’t even discussed testing when it comes to navigation stacks. But there’s a good reason. The navigation stack tools are a lot more complicated than the presentation tools, and so it was good to just focus on the tools in isolation to start. And on top of that, testing features in a navigation stack is quite a bit more complicated that testing features presented with optional or enum state, and so that’s yet another reason to delay the discussion a bit.
— 1:12
But we are now ready to face it head on. We are going to start by showing what it’s like to test our little toy application as it exists right now, and then see how we can make testing navigation stacks more ergonomic and more powerful. A basic test
— 1:25
Let’s quickly try to write a very basic test for our toy app. I’ll start by pasting in some scaffolding into a new test file: import ComposableArchitecture import XCTest @testable import Inventory @MainActor class StackExplorationTests: XCTestCase { func testBasics() async { } }
— 1:38
Let’s start very, very simple. Can we write a test that just simulates the user tapping a button to push a counter feature to the stack, and then immediately popping it back off?
— 1:46
Well first we need to create a test store for our feature’s domain: let store = TestStore( initialState: RootFeature.State(), reducer: RootFeature() )
— 1:59
And then we can send actions to the test store to simulate what the user is doing and assert on how state changes.
— 2:06
The action that is sent when the user taps on a NavigationLink is push , so perhaps we can start with that: await store.send(.path(.push(<#RootFeature.Path.State#>)))
— 2:19
From here we can use autocomplete to see what choices we have. We see the 3 features that can be pushed onto the stack: the counter feature, the number fact feature, and the prime number feature. Let’s push a counter feature to the stack, which thanks to the default we provided in the enum case has a very succinct syntax: await store.send(.path(.push(.counter())))
— 2:29
Once we send that action we want to assert how state changed. In particular, we think a single value should have been pushed to the stack, but how do we assert that: await store.send(.path(.push(.counter()))) { <#???#> }
— 2:41
One of the only methods we have on StackState that allows mutations is the append method, so maybe we should try that: await store.send(.path(.push(.counter()))) { $0.path.append(.counter()) }
— 3:05
However, running this already produces 2 test failures: testBasics(): A state change does not match expectation: … RootFeature.State( path: [ [0]: ( id: UUID( − 1BEC557A-C559-4FCE-8694-242BA90EA889 + C825C300-E3F1-4183-BB79-210E6492EE6D ), element: .counter(…) ) ] ) (Expected: −, Actual: +) Failed: testBasics(): An effect returned for this action is still running. It must complete before the end of the test. …
— 3:15
The first failure is not really that surprising since we’ve already remarked a few times that we are generating fresh, random UUIDs in our library code, and that is of course going to complicate testing. The second is a little more surprising because we aren’t even dealing with any effects right now. We are just pushing a feature onto the stack and then popping it off.
— 3:42
But let’s just keep moving on. After we push a counter feature to the stack we next want to pop it from the stack. We can do that by emulating the user tapping the “Back” button by sending a popFrom action: await store.send(.path(.popFrom(id: <#UUID#>)))
— 3:57
But what ID should we provide? Again, these UUIDs are randomly generated and so we have no way of reliably predicting what the ID will be at this moment.
— 4:10
We do have access to the current path in the test store: await store.send( .path(.popFrom(id: store.state.path.<#⎋#>)) )
— 4:24
…but that doesn’t help much since not very much of the internals of StackState is publicly exposed to us.
— 4:28
I think we can actually expose a bit more while still keeping the type under our control, in particular maintaining the IDs under the hood. For example, I don’t see any reason why we couldn’t publicly expose the IDs of all the elements inside the stack as an ordered set: import OrderedCollections struct StackState<Element> { … var ids: OrderedSet<UUID> { self.elements.ids } … }
— 4:58
That seems totally reasonable, and in fact gives us the exact tool we need to send the popFrom action: await store.send( .path(.popFrom(id: store.state.path.ids[0])) )
— 5:18
And once that action is sent we expect the path to be fully cleared out: await store.send( .path(.popFrom(id: store.state.path.ids[0])) ) { $0.path = StackState() }
— 5:25
Alright, and let’s run tests again.
— 5:28
OK, it still fails, but interestingly it’s the same 2 test failures as before. In particular, this new assertion we just wrote that sends the popFrom action is passing, which means that the path really is clearing out.
— 5:40
Also remember that we added an explicit test failure if you ever try popping off an element that doesn’t actually exist. So, if we had changed the assert to use a randomly generated UUID like this: await store.send(.path(.popFrom(id: UUID()))) { … }
— 5:54
…then we will get a test failure letting us know that is a programmer error: testBasics(): Tried popping an element off the stack that does not exist.
— 6:07
So it’s really cool to see how the testing tools in the Composable Architecture can keep us in check and force us to truly assert on what is happening in the system. But, let’s undo that UUID change now and get back to our original 2 test failures.
— 6:20
So, we still have these two test failures. The first is quite involved to fix and the second is actually quite straightforward to fix, so we will address them in reverse order.
— 6:29
The reason we are getting a test failure talking about an effect still running is due to how we handle the child dismissal effect. That is done by firing up a long living effect while the feature is being presented so that we can listen when a particular ID is cancelled, and that allows us to send the popFrom action.
— 6:46
We actually just have a small bug in our child dismissal logic in the forEach operator. We need to mark the long living “on first appear effect” as cancellable so that it is cancelled when the feature goes away: let onFirstAppearEffects: Effect<Action> = .merge( idsAfter.subtracting(idsMounted).map { id in state[keyPath: toElementsState].mounted.insert(id) return .run { send in … } .cancellable(id: id) } )
— 7:19
And that’s all it takes. With that one small change we are down to just 1 test failure when running tests: testBasics(): A state change does not match expectation: …
— 7:27
And this is the test failure that is happening due to our uncontrolled UUID dependency. We will be fixing that, but let’s leave the test as-is for now and explore how difficult it is to write other kinds of tests for our application.
— 7:37
For example, what does it look like to send actions from a child feature on the stack and assert how the state changes inside individual elements of the stack?
— 7:45
Well, let’s start up a new test method: func testCounterFeature() async { }
— 7:51
And this time let’s create a test store with some initial state where we are already deep-linked 1 level deep: let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter() ]) ), reducer: RootFeature() )
— 8:09
Next we want to send an action into the store from the perspective of the child counter feature in the stack. To do this we can take it one step at a time and let Xcode autocomplete help us. For example, right off the bat we have to figure out what kind of StackAction we want to send: await store.send(.path(<#StackAction#>))
— 8:26
Our choices are element , popFrom and push . We want an element action because those are the actions coming from the individual elements in the stack: await store.send( .path( .element(id: <#UUID#>, action: <#RootFeature.Path.Action#>) ) )
— 8:36
Now we need the ID of the feature in the stack we want to send the action for, as well as specify the kind of feature we want to send the action for. We can again manually grab the ID by reaching into the store’s state: await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(<#CounterFeature.Action#>) ) ) )
— 8:56
And now finally we can specify the counter feature action we want to send. Let’s just try tapping the increment button: await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.incrementButtonTapped) ) ) )
— 9:07
Now we need to assert on how state changed.
— 9:13
This can happen in a quite similar fashion as what we encountered testing features built with a Destination enum reducer where we used XCTModify to single out a particular case of an enum in order to perform a mutation: XCTModify(<#&Root#>, case: <#CasePath<Root, Case>#>) { }
— 9:28
Recall that XCTModify takes a mutable enum value and a case path that singles out a case of the enum. Then, the trailing closure is handed a mutable version of the associated data of that case, and allows you to make any mutations to it you want. However, if the root value provided does not match the case specified by the case path, then XCTModify will cause a test failure, so it does keep you in check.
— 9:53
So, how do we get an enum value to feed to XCTModify ? Well, our Path enum is held in the elements of the stack, so we need to somehow subscript into the path: XCTModify(&$0.path[<#???#>], case: <#CasePath<Root, Case>#>) { }
— 10:08
But what can we subscript in with? We do have access to a subscript thanks to the Collection protocol conformance, but that subscript uses positional, integer-based indices. We’ve seen over and over again in past Point-Free episodes that positional indices are not great for collections of features that manage asynchronous work. It opens you up to the possibility of accidentally updating the wrong data in the collection, or even accessing an element that no longer exists.
— 10:31
And even if we wanted to use a position index it wouldn’t help: XCTModify(&$0.path[0], case: <#CasePath<Root, Case>#>) { }
— 10:37
This is because we made that subscript read-only: Cannot pass immutable value as inout argument: subscript is get-only
— 10:45
It does not have a setter. And that was on purpose. We do not want people mutating the stack through positional indices. The only reason integer indices are exposed is because we conformed to the Collection protocol so that you could iterate over the stack, and for that purpose integer indices are totally fine.
— 11:01
So, what we are seeing is that we do need a way to mutate a single element of the stack, and we need to do so in a manner that is safer than with positional indices. Well, the identified array type has such an operation. It’s a subscript that takes an ID and allows reading and writing an optional Element value: @inlinable @inline(__always) public subscript(id id: ID) -> Element? { _read { yield self._dictionary[id] } _modify { yield &self._dictionary[id] precondition( self._dictionary[id].map { self._id($0) == id } ?? true, "Element identity must remain constant" ) } }
— 11:23
This makes identified arrays behave a little like dictionaries, since reading from an ID returns an optional and writing a nil value has the effect of removing the element at that ID.
— 11:32
Let’s expose a similar subscript on StackState : struct StackState<Element> { … subscript(id id: UUID) -> Element? { get { self.elements[id: id]?.element } set { self.elements[id: id] = newValue.map { .init(id: id, element: $0) } } } }
— 12:21
We are using simpler get and set constructs rather than the fancy _read and _modify , but it is basically the same.
— 12:31
Now we can use that subscript in the test by grabbing the first ID from the stack and then using that in the subscript: XCTModify( &$0.path[id: $0.path.ids[0]], case: <#CasePath<Root, Case>#> ) { }
— 12:49
Next we will specify the case we want to modify, which is the counter case of the Path.State enum: XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { }
— 13:05
And finally we can mutate the data in the counter feature case to set its count to 1: XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 1 }
— 13:17
Let’s finish off the test by popping the feature off the stack: await store.send( .path(.popFrom(id: store.state.path.ids[0])) ) { $0.path = StackState() }
— 13:45
If we run this test we will see it passes! So, we don’t even have to worry about randomly generated UUIDs for this kind of test. We can just reach directly into the state to grab any IDs we need. Testing child effects
— 13:58
This is all looking OK so far. One test that seemed like it should be really easy to write, the act of simply pushing and popping a feature from the stack, turned out to be complicated since we have an uncontrolled dependency on UUIDs. And then other test we wrote to exercise the logic inside a feature in the stack worked out really well. The test passes and so we can have confidence that the features are integrated together properly. But, also that test was really messy, forcing us to reach into the state’s path’s IDs set just so that we could actually write our assertions. Brandon
— 14:25
So, we are already seeing some of the problems with testing: 1.) some are hard to write and get to pass, and 2.) the ones that pass are really messy. We are going to get around to fix those problems, but let’s push forward for a little bit longer to see what other kinds of problems we might have.
— 14:41
So far we have only tested the most basic of logic in the counter feature, so let’s now try exercising some more of its behavior. That is, how does it execute effects and how does that interact with the parent feature?
— 14:54
Let’s dig in.
— 14:57
Let’s write a new test that exercises the timer feature in the counter. I’m going to copy-and-paste the test we just wrote: func testCounterFeature_Timer() async { … }
— 15:09
Then, instead of incrementing the counter we will send the .toggleTimerButtonTapped action and assert that the isTimerOn boolean flipped to true : await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.toggleTimerButtonTapped) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.isTimerOn = true } }
— 15:36
Now the timer effect is running in the background, and we can start asserting on how it emits data back into the system. For example, we can assert that we eventually receive a timerTick action: await store.receive(.<#⎋#>)
— 15:55
However, in order to use store.receive with a concrete received action we need the Action enum to conform to Equatable . There is a way around this using a technique we should in episodes at the beginning of our navigation series, in which you specify a case path to broadly describe the action you expect to receive rather than describe the precise action: await store.receive(/RootFeature.Action.path) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 1 } }
— 16:39
That would work too, but let’s be a little more precise. I’m going to go ahead and make everything equatable because it’s quite easy to do since all of our Action types just hold simple data.
— 17:01
And we now need to make StackAction conditionally equatable: extension StackAction: Equatable where State: Equatable, Action: Equatable {}
— 18:17
Everything now compiles, and we can be more precise with exactly what action we expect to receive from the effect: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 1 } }
— 18:26
But unfortunately this does not pass. We get the following failure: testCounterFeature_Timer(): Expected to receive an action, but received none after 0.1 seconds.
— 18:41
This is happening due to something we warned about multiple times while building this toy application, and it is finally come to fruition. We have decided to make use of global, uncontrolled dependencies in our application, and that means testing is going to be a lot more difficult. This failure is letting us know that we asserted that we should have received an action, yet not action was received. The test store even waited around for a small amount of time to make sure it didn’t miss anything.
— 18:54
Well, our timer is on a 1 second interval, and the timeout for store.receive is a mere 0.1 seconds. So, we just have to up the timeout: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ), timeout: .seconds(1) ) { … }
— 19:06
And now the test passes. The test suite takes a lot longer too, just over a second. But at least it passes.
— 19:09
Let’s exercise a few more timer ticks by copying and pasting this code and updating the count mutation: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ), timeout: .seconds(1) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 2 } } await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ), timeout: .seconds(1) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 3 } }
— 19:20
Well unfortunately now we are back to a failing test. I guess the 1 second timeout isn’t always enough. Let’s increase it a small amount: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ), timeout: .seconds(2) ) { … } await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ), timeout: .seconds(2) ) { … } await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ), timeout: .seconds(2) ) { … } Now the test is passing pretty consistently. I don’t know if this is always going to be enough of a timeout, but seems to be sufficient for now.
— 19:51
However, our test suite has now gotten much slower. The other test methods take a fraction of a second to pass, and now this one takes over 3 seconds. Let’s finally take back control over our dependency on time rather than letting it control us.
— 20:11
Luckily it is very straightforward to do. Right now we are emulating a timer in an effect by performing an explicit Task.sleep : if state.isTimerOn { // Start up a timer return .run { send in while true { try await Task.sleep(for: .seconds(1)) await send(.timerTick) } } .cancellable(id: CancelID.timer) } else { return .cancel(id: CancelID.timer) }
— 20:25
This Task.sleep is completely uncontrollable. We have no choice but to wait for real world time to pass in order for it to un-suspend. We can take back control over this by using an explicit clock that we can control from the outside.
— 20:40
So, we will add a dependency to our feature on a continuous clock: struct CounterFeature: Reducer { … @Dependency(\.continuousClock) var clock … }
— 20:51
And then we will make use of the clock rather than reaching out to Task.sleep : while true { try await self.clock.sleep(for: .seconds(1)) await send(.timerTick) }
— 21:05
Even better, thanks to our Clocks library, we have a timer method that more accurately accounts for any drift that can accumulate over time with sleep , and is even a bit simpler, so let’s use that: for await _ in self.clock.timer(interval: .seconds(1)) { await send(.timerTick) }
— 21:38
We have another spot in the reducer where we were simulating some effectful work by reaching out to Task.sleep , so let’s update that to use the clock too: case .loadAndGoToCounterButtonTapped: state.isLoading = true return .run { send in try await self.clock.sleep(for: .seconds(2)) await send(.loadResponse) }
— 21:57
With that change done let’s just try running the tests again. We’ll see that now for some reason it fails: testCounterFeature_Timer(): Unimplemented: ContinuousClock.now …
— 22:08
This is happening because we started making use of a controllable clock in our feature, but we didn’t override the dependency in our tests. This is a great failure to have because it notifies you when you start using a dependency in a feature that you weren’t explicitly asserting on, and helps make sure you don’t accidentally interact with live dependencies in tests.
— 22:31
So, let’s provide an explicit clock for our tests. Since we want to step through time, second by second, we can use a TestClock : func testCounterFeature_Timer() async { let clock = TestClock() let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter() ]) ), reducer: RootFeature() ) { $0.continuousClock = clock } … }
— 22:51
This is a kind of clock that simply suspends forever when you to tell it to sleep until you tell it to advance its internal time.
— 23:01
With that done we can now tell the clock to advance time explicitly rather than using a timeout on store.receive : await clock.advance(by: .seconds(1)) await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { … } await clock.advance(by: .seconds(1)) await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { … } await clock.advance(by: .seconds(1)) await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { … } This is a far more precise tool. We get to assert exactly how much time needs to pass in order for the effect to do its job.
— 23:30
The test also now passes, and does so in a fraction of second: Test Suite 'Selected tests' passed at 2023-04-25 10:40:37.495. Executed 1 test, with 0 failures (0 unexpected) in 0.345 (0.349) seconds
— 23:40
It should also be pointed out that the fact that this test is passing is also proving that our forEach operator is definitely tearing down child feature effects when the feature is popped from the stack. If effects were not cancelled then we would have a test failure letting us know that the timer effect is still inflight.
— 23:59
In fact, if we make one small change to the forEach operator to no longer cancel any effects: let cancelEffects: Effect<Action> = .merge( idsBefore.subtracting(idsAfter).map { id in .none // .cancel(id: id) } )
— 24:17
…then we suddenly get a test failure: testCounterFeature(): An effect returned for this action is still running. It must complete before the end of the test. …
— 24:22
So this is great to see. We can now see in more precise terms that our forEach operator really is working the way we expect, in that it cancels child effects when the feature is dismissed. Of course the test is really verbose and kind of a pain to write, but at least it passes.
— 24:46
While we are here, let’s also see what happens if you accidentally try sending a child action into the system that is not actually in the stack. I’m going to start by copying and pasting the test we just wrote and renaming it: func testCounterFeature_InvalidAction() async { … }
— 25:04
We’ll get rid of all the test clock stuff and timer stuff, and instead we will just send a numberFact feature action, which isn’t even on the stack at all, and then pop the counter feature off the stack: func testCounterFeature_InvalidAction() async { let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter() ]) ), reducer: RootFeature() ) await store.send( .path( .element( id: store.state.path.ids[0], action: .numberFact(.factButtonTapped) ) ) ) await store.send( .path(.popFrom(id: store.state.path.ids[0])) ) { $0.path = StackState() } }
— 25:26
This gives us a failure letting us know that we are trying to process a NumberFact.Action for a piece of enum state that holds onto CounterFeature.State : testCounterFeature_InvalidAction(): A “Scope” at “Inventory/StackExplorations.swift:316” received a child action when child state was set to a different case. … Action: RootFeature.Path.Action.numberFact( .factButtonTapped ) State: RootFeature.Path.State.counter This is generally considered an application logic error, and can happen for a few reasons:
— 25:41
This is also a great failure to have. It is letting us know we are definitely doing something wrong, and so we should be notified of it.
— 25:47
So that’s great to see, but let’s get it to pass by telling XCTest that we actually expect this test to fail: func testCounterFeature_InvalidAction() async { XCTExpectFailure() … }
— 26:04
And now the test suite passes. Testing child integration
— 26:10
So, this is looking pretty great. Tests are a little messy and verbose, but we are seeing that we can actually test the complex and nuanced logic. We are testing how a counter feature runs inside the larger stack feature, and each step of the way the library has our back to make sure we are asserting on everything happening. Stephen
— 26:28
This is kind of fun, let’s write a few more tests before we start addressing the problems and shortcomings of testing stacks. That way we will have a large corpus of tests so that we can see in concrete terms just how our improvements will affect testing in practice.
— 26:42
The next kind of test we want to write has to do with “integration” of parent and child features. We have a few places where there is some simple communication between parent and child:
— 26:52
First we added the feature that emulated some effectful work being done before performing a drill-down. That got us to demonstrate how the child feature can tell the parent to navigate. Brandon
— 27:02
Then we added a summary view to our stack that aggregated data across the entire stack. That allowed us to demonstrate how the parent domain gets complete and infinite introspection into what is happening inside the stack, which was really cool. Stephen
— 27:16
And then finally we demonstrated how a child feature can dismissal itself without any direct communication with the parent, and that allowed us to have very subtle and nuanced logic around dismissal without the parent knowing anything about it.
— 27:28
So, let’s quickly write tests for these 3 use cases. Testing effectful navigation
— 27:34
We’ll start with the integration test that proves that when the child counter feature performs a “load and go to counter” action, that eventually the parent pushes a new feature onto the stack. Recall that this behavior emulates effectful work being done by sleeping for a small amount of time, and we now do that with a clock, so we should override the continuousClock dependency. This time we will just use an ImmediateClock because we don’t care about explicitly advancing time. We just want to assert that eventually something happens: func testCounterFeature_LoadAndGoToCounter() async { let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 42)) ]) ), reducer: RootFeature() ) { $0.continuousClock = ImmediateClock() } }
— 28:49
Now we can emulate the user tapping the “Load and go to counter” button, and assert that the isLoading boolean flips to true : await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.loadAndGoToCounterButtonTapped) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.isLoading = true } }
— 29:27
Then we expect that a moment later we receive a loadResponse action, which flips the isLoading boolean back to false : await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.loadResponse) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.isLoading = false } }
— 29:54
Then we can assert on the mechanism that the child feature uses to communicate back to the parent. It synchronously sends a delegate action back into the system that the parent listens for: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.delegate(.goToCounter(42))) ) ) ) { }
— 30:17
When the parent sees this action come into the system it pushes a new feature onto the stack. Unfortunately we’re back in the tough spot that we saw earlier in which we have no nice, testable way to push a new feature to the stack. So, let’s just do it in the only way we know how to keep moving forward: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.delegate(.goToCounter(42))) ) ) ) { $0.path.append( .counter(CounterFeature.State(count: 42)) ) }
— 30:39
And then finally let’s just pop the feature off the stack: await store.send( .path(.popFrom(id: store.state.path.ids[0])) ) { $0.path = StackState() }
— 30:58
This test is very close to passing, but we have one failure due to the uncontrolled use of the UUID initializer: testCounterFeature_LoadAndGoToCounter(): A state change does not match expectation: … RootFeature.State( path: [ [0]: (…), [1]: ( id: UUID( − DCF1CC91-68EF-4CA7-9739-F134AAC2566C + 038D0F71-1C30-45E5-9EFC-769D8D714B3B ), element: .counter(…) ) ] ) (Expected: −, Actual: +)
— 31:14
This is exactly what we saw before, and unfortunately we can’t fix this just yet, but we will be looking at it soon.
— 31:21
But even before that it’s still pretty cool how easy it is to test the integration of these two features. The counter feature has no idea that it is even embedded in a navigation stack, yet it is still able to easily communicate to the parent domain so that it can layer on additional functionality.
— 31:37
Also, integration tests like this give us a great opportunity make use of the TestStore ’s non-exhaustive testing facilities. By default the test store wants us to assert on everything happening inside your features, including exactly how every piece of state changes, how every effect feeds data back into the system, and making sure that all effects finish when the test is finished.
— 32:05
This is really great for making sure you can prove exactly how your entire feature behaves, but also the closer you test to the root of your application the more cumbersome it is. You start needing to assert on tons of logic that doesn’t have anything to do with the integration of the features that you are actually trying to test.
— 32:22
So, let’s see how much simpler things get if we copy-and-paste this test and turn off exhaustivity: func testCounterFeature_LoadAndGoToCounter_NonExhaustive() async { let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 42)) ]) ), reducer: RootFeature() ) { $0.continuousClock = ImmediateClock() } store.exhaustivity = .off }
— 32:51
Now we get to assert on the very high level details of how the counter feature integrates with the parent feature without asserting on everything .
— 32:58
For example, we could even go as slim as simply tapping the “Load and go to counter” button, and then only asserting that at some point later we get a delegate action that inserts a new counter feature: await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.loadAndGoToCounterButtonTapped) ) ) ) await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.delegate(.goToCounter(42))) ) ) ) { $0.path[id: $0.path.ids[1]] = .counter(CounterFeature.State(count: 42)) } We aren’t asserting on state changes in the child feature, nor are we asserting on intermediate effect actions in the child domain, and we can even mutate the path more directly since we do actually have the final ID in this trailing closure. The test still passes, and we have little informational callouts to let us know all the things that we did not assert on. Testing the summary
— 34:20
So this is looking pretty great. We get amazing testing capabilities when it comes to the integration of multiple features, and we can decide just how much exhaustivity we want. Brandon
— 34:29
Let’s move onto another integration point that we would like some test coverage on: the summary. This feature had some nuanced logic around aggregating the counts across all of the features in the stack.
— 34:41
For example, we can exercise the flow of starting with an empty stack, and assert that the computed sum starts at 0: func testSummary() async { let store = TestStore( initialState: RootFeature.State(), reducer: RootFeature() ) } XCTAssertEqual(store.state.sum, 42)
— 35:24
Then we can push on a counter feature and see that the sum computed property jumped a bit await store.send( .path( .push(.counter(CounterFeature.State(count: 42))) ) ) { $0.path.append( .counter(CounterFeature.State(count: 42)) ) } XCTAssertEqual(store.state.sum, 42)
— 35:54
And we can push another counter feature to see it jump a bit more: await store.send( .path( .push(.counter(CounterFeature.State(count: 1_729))) ) ) { $0.path.append( .counter(CounterFeature.State(count: 1_729)) ) } XCTAssertEqual(store.state.sum, 1_771)
— 36:12
And then we can start popping off the features to see the sum go back to 0: await store.send( .path(.popFrom(id: store.state.path.ids[1])) ) { $0.path.pop(from: $0.path.ids[1]) } XCTAssertEqual(store.state.sum, 42) await store.send( .path(.popFrom(id: store.state.path.ids[0])) ) { $0.path = StackState() } XCTAssertEqual(store.state.sum, 0) And to exercise some features that don’t affect the sum, we can push a non-counter feature onto the stack from the deepest point to confirm that the sum is unchanged: await store.send( .path( .push( .numberFact(NumberFactFeature.State(number: 1_729)) ) ) ) { $0.path.append( .numberFact(NumberFactFeature.State(number: 1_729)) ) } XCTAssertEqual(store.state.sum, 1_771)
— 37:41
This test mostly passes. The only failures we have are again due to the uncontrolled UUID initializer, which we will be attacking soon.
— 37:58
So, that’s cool, but the non-exhaustive version if even cooler: func testSummary_NonExhaustive() async { let store = TestStore( initialState: RootFeature.State(path: StackState()), reducer: RootFeature() ) store.exhaustivity = .off }
— 38:08
Now we get to just assert on the bare essentials of what we actually care about, in particular that the sum computed property goes up and down and has features are pushed and popped: await store.send( .path(.push(.counter(CounterFeature.State(count: 42)))) ) XCTAssertEqual(store.state.sum, 42) await store.send( .path( .push(.counter(CounterFeature.State(count: 1_729))) ) ) XCTAssertEqual(store.state.sum, 1_771) await store.send( .path( .push( .numberFact(NumberFactFeature.State(number: 1_729)) ) ) ) XCTAssertEqual(store.state.sum, 1_771) await store.send( .path(.popFrom(id: store.state.path.ids[1])) ) XCTAssertEqual(store.state.sum, 42) await store.send( .path(.popFrom(id: store.state.path.ids[0])) ) XCTAssertEqual(store.state.sum, 0)
— 38:44
This test passes just fine.
— 38:46
To see the assertions that we’re skipping we can tweak the store’s exhaustivity. This puts grey informational boxes on every line that isn’t making an exhaustive assertion.
— 39:11
We can even up the ante a bit. What if we started the timer in each counter feature pushed onto the stack to assert that when the timer ticks a few times it causes the sum to count up too: func testSummary_NonExhaustive() async { let clock = TestClock() let store = TestStore( initialState: RootFeature.State( path: StackState() ), reducer: RootFeature() ) { $0.continuousClock = clock } store.exhaustivity = .off(showSkippedAssertions: true) await store.send( .path( .push(.counter(CounterFeature.State(count: 42))) ) ) XCTAssertEqual(store.state.sum, 42) await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.toggleTimerButtonTapped) ) ) ) await store.send( .path( .push(.counter(CounterFeature.State(count: 1_729))) ) ) await store.send( .path( .element( id: store.state.path.ids[1], action: .counter(.toggleTimerButtonTapped) ) ) ) XCTAssertEqual(store.state.sum, 1_771) await clock.advance(by: .seconds(5)) XCTAssertEqual(store.state.sum, 1_781) … }
— 40:53
We have a few failures, starting with: testSummary_NonExhaustive(): XCTAssertEqual failed: (“1771”) is not equal to (“1773”)
— 40:59
We can’t assert on state immediately after advancing the clock, because the timerTick actions haven’t been processed yet. To advance the store, we can call skipReceivedActions : XCTAssertEqual(store.state.sum, 1_771) await clock.advance(by: .seconds(5))
— 41:20
But we still have more failures: testSummary_NonExhaustive(): XCTAssertEqual failed: (“47”) is not equal to (“1781”) Failed: testSummary_NonExhaustive(): Tried popping an element off the stack that does not exist. Somehow it seems that the sum property reset all the way back down to 47, which is 5 greater than the 42 we started at. The 1,729 on the stack doesn’t even seem to factor into the sum at all for some reason.
— 41:43
Well, this is actually because of the child dismissal logic we added to the feature in a previous episode. We made it so that when the timer ticks and the state’s count is greater than 100, we pop the feature off the stack: if state.count >= 100 { return .fireAndForget { await self.dismiss() } } else { And 1,729 is definitely greater than 100, and so it is getting popped off the stack. So, our assertion was just wrong. 47 really is the correct answer.
— 42:00
Let’s adapt the test real quick so that we don’t run into that child dismissal logic because we will test that in a moment: await store.send( .path(.push(.counter(CounterFeature.State(count: 42)))) ) XCTAssertEqual(store.state.sum, 42) await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.toggleTimerButtonTapped) ) ) ) await store.send( .path(.push(.counter(CounterFeature.State(count: 55)))) ) await store.send( .path( .element( id: store.state.path.ids[1], action: .counter(.toggleTimerButtonTapped) ) ) ) XCTAssertEqual(store.state.sum, 97) await clock.advance(by: .seconds(5)) await store.skipReceivedActions() XCTAssertEqual(store.state.sum, 107)
— 43:01
Now this passes and we can see that as the timer ticks it causes the sum property to also increment.
— 43:15
So, that is another subtle yet deep integration test we under our belts. We can assert on how the parent feature aggregates data across the entire stack, and we can choose just how exhaustive we want to be when writing such tests. Testing child dismissal
— 43:27
Let’s take a look at the last kind of integration test, and its the one we butted heads with a moment ago. We will write a test that proves that the child counter feature can dismiss itself when it wants to, all without ever communicating with the parent directly.
— 43:41
Since the dismissal logic involves a timer we will need to override the continuousClock dependency. But this time we don’t need the full power of a test clock. We just want to start the timer, let it go as high as it needs to trigger the child dismissal, which is 100, and then assert that the dismissal happened. This means we can just use an immediate clock: func testCounterFeature_TimerDismissal() async { let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 97)) ]) ), reducer: RootFeature() ) { $0.continuousClock = ImmediateClock() } } We’ll also start the test store in a state where it already has a counter feature pushed onto the stack, as well as its count being pretty close to 100.
— 44:38
Then we’ll emulate the user starting the timer: await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.toggleTimerButtonTapped) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.isTimerOn = true } }
— 45:11
Then we can assert that we receive 3 ticks from the timer: await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 98 } } await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 99 } } await store.receive( .path( .element( id: store.state.path.ids[0], action: .counter(.timerTick) ) ) ) { XCTModify( &$0.path[id: $0.path.ids[0]], case: /RootFeature.Path.State.counter ) { $0.count = 100 } }
— 45:48
Before finally receiving a popFrom action, which is the child telling the parent it wants to be dismissed: await store.receive( .path(.popFrom(id: store.state.path.ids[0])) ) { $0.path = StackState() }
— 46:08
This test passes and proves that indeed child features embedded in a stack can dismiss themselves off the stack without any direct communication to the parent. It’s amazing to see.
— 46:19
Even better, the non-exhaustive version of this test can truly hone in on just the bare essentials in cares about. In particular, that when a timer is turned on that eventually the feature is popped off the stack: func testCounterFeature_TimerDismissal_NonExhaustive() async { let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 97)) ]) ), reducer: RootFeature() ) { $0.continuousClock = ImmediateClock() } store.exhaustivity = .off await store.send( .path( .element( id: store.state.path.ids[0], action: .counter(.toggleTimerButtonTapped) ) ) ) await store.receive( .path(.popFrom(id: store.state.path.ids[0])) ) }
— 47:09
And this test passes too. Fixing problems with testing
— 47:11
OK, so we have seen some truly powerful things when it comes to testing navigation stacks of features, but also we have seen some problems along the way. For one thing we keep running into the problem of having uncontrolled UUIDs in our state, which makes it impossible to exhaustively assert how state changes. And second, some of these tests are really gnarly. We are reaching into the store’s state just to grab IDs and pass them around. There’s got to be a better way. Brandon
— 47:36
And luckily there is. We can actually make testing navigation stacks really nice and ergonomic. Let’s start fixing all of these problems by first controlling our dependency on the UUID initializer and see how that affects things.
— 47:49
If we hop over to Navigation.swift we will find 3 places we are constructing a UUID in an uncontrollable manner. First is in our NavigationLink initializer, but that’s not actually a big deal. This type is only used in views, which we don’t test directly anyway, so this can stay how it is.
— 48:18
The second is when initializing a StackState from scratch using a sequence of elements: init<S: Sequence>(_ elements: S) where S.Element == Element { self.elements = IdentifiedArray( uncheckedUniqueElements: elements.map { Component(id: UUID(), element: $0) } ) }
— 48:26
And the third is when appending a new element to StackState : mutating func append(_ element: Element) { self.elements.append( Component(id: UUID(), element: element) ) }
— 48:36
These are easy enough to fix. We will just have StackState depend on the controllable UUID generator that comes with our swift-dependencies library, and is also included automatically in all Composable Architecture applications: struct StackState<Element> { @Dependency(\.uuid) var uuid … }
— 48:49
Now we will use this dependency rather than reaching out to the global, uncontrollable initializer: init<S: Sequence>(_ elements: S) where S.Element == Element { self.elements = IdentifiedArray( uncheckedUniqueElements: elements.map { Component(id: self.uuid(), element: $0) } ) } mutating func append(_ element: Element) { self.elements.append( Component(id: self.uuid(), element: element) ) }
— 49:00
That’s all it takes, and so let’s run our test suite: testBasics(): @Dependency(\.uuid) has no test implementation, but was accessed from a test context:
— 49:03
We now have a bunch of new failures because we are making use of the UUID dependency without overriding it in tests. So, I guess we have to override the dependency in every single one of our tests. That sounds like a pain, but let’s give it a shot in a few tests to see what it buys us.
— 49:32
Take our very first test as an example. It just wanted to assert the behavior of pushing a counter feature onto the stack and then immediately popping it off. Let’s update it to override the \.uuid dependency with something more deterministic. In particular, we can use the .incrementing UUID generator, which simply sequentially counts up every single time you ask for a UUID: func testBasics() async { let store = TestStore( initialState: RootFeature.State(), reducer: RootFeature() ) { $0.uuid = .incrementing } … }
— 50:01
Now when we run this test we get a failure that is a little more reasonable: testBasics(): A state change does not match expectation: … RootFeature.State( path: [ [0]: ( id: UUID( − 00000000-0000-0000-0000-000000000001 + 00000000-0000-0000-0000-000000000000 ), element: .counter(…) ) ] ) (Expected: −, Actual: +)
— 50:07
This is now at least showing a UUID that is something we could predict. Previously it was just a random sequence of hex digits.
— 50:15
And the reason there is a mismatch here is because when the reducer logic ran to append the counter to the stack it generated a UUID, and then when we performed the assertion we generated another ID.
— 50:34
The best fix for this would be for the TestStore to reset the UUID generator for the scope of the assertion closure because it’s kind of weird that the things we do inside that closure may bleed into the outside. That will be done in the final version of these tools that ship in the library, but there’s another way of performing this assertion that avoids the problem entirely.
— 50:54
We can subscript into the path with an ID in order to add the counter to the stack: await store.send(.path(.push(.counter()))) { $0.path[id: <#UUID#>] = .counter() }
— 51:01
And luckily this UUID is now predictable: await store.send(.path(.push(.counter()))) { $0.path[ id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )! ] = .counter() } And it passes!
— 51:22
Even better, our swift-dependencies library comes with a little helper that allows generating number-based UUIDs like this: await store.send(.path(.push(.counter()))) { $0.path[id: UUID(0)] = .counter() }
— 51:37
We can even use this helper to update the next store.send line: await store.send(.path(.popFrom(id: UUID(0)))) { $0.path = StackState() }
— 51:51
And this test still passes! So we are now seeing how we will be able to predict the IDs that are used under the hood in the StackState and we are seeing how the ergonomics are even improving a little bit.
— 52:05
Let’s check out some more tests. For example, the next test wants to exercise the flow of starting with a counter feature in the stack, then tapping the increment button in that feature. We can now override the \.uuid dependency to use an auto-incrementing generator and then hard code the IDs throughout the test: func testCounterFeature() async { let store = TestStore( initialState: RootFeature.State( path: StackState([ .counter() ]) ), reducer: RootFeature() ) { $0.uuid = .incrementing } await store.send( .path( .element(id: UUID(0), action: .counter(.incrementButtonTapped)) ) ) { XCTModify( &$0.path[id: UUID(0)], case: /RootFeature.Path.State.counter ) { $0.count = 1 } } await store.send(.path(.popFrom(id: UUID(0)))) { $0.path = StackState() } }
— 52:40
This test now passes, and we are no longer reaching into the store’s state in order to grab IDs. So that’s great.
— 52:55
In fact, we can now fix all tests in a similar manner, so let’s quickly do that…
— 53:33
In the testCounterFeature_LoadAndGoToCounter test, where we receive a delegate action that pushes a counter on the stack, we can use the predictability of our UUIDs to assert that the autoincrementing UUID of 1 is pushed onto the stack: $0.path[id: UUID(1)] = .counter( CounterFeature.State(count: 42) )
— 54:05
And let’s finish off the rest of the tests…
— 54:32
And now the whole suite should pass.
— 54:40
It took a lot of work to update all these tests, but we have a much nicer way of writing them: a lot of lines that used to wrap now fit on a single line, and they all pass for the first time. Ergonomic testing
— 54:58
So this is looking pretty good. With just one small change, that of using a controllable UUID generator rather than the global, uncontrollable one, we have been able to get all of our tests passing, and we have even made writing tests a little more ergonomic. Stephen
— 55:13
However, we can do much better. First, it is not ideal to have to constantly override the \.uuid dependency for every test that touches a navigation stack. We shouldn’t consider it an error if you don’t override the dependency, and instead the UUID generator should automatically use the incrementing version. Well, for navigation stacks that is. We would still want it to be a test failure if you access a UUID generator in your feature’s code.
— 55:37
I think that this is telling us that it’s not quite right to shoehorn the UUID generator dependency into our navigation stack tools.
— 55:43
Instead we should probably have a brand new dependency for generating IDs for navigation stacks that is independent from the UUID generator that comes with our swift-dependencies library. That would allow us to give it more custom behavior and we can even use it to make our test syntax more concise.
— 55:58
Let’s give it a shot.
— 56:02
Let’s model a brand new dependency that is just for navigation stack IDs: struct StackElementIDGenerator { let next: () -> UUID func callAsFunction() -> UUID { self.next() } }
— 56:36
It can generate UUIDs, but it can also have its own live and test implementations. In particular, it can use the live UUID initializer for its live value, and use the auto-incrementing generator for the test value: extension StackElementIDGenerator: DependencyKey { static let liveValue = Self { UUID() } static var testValue: StackElementIDGenerator { let uuid = UUIDGenerator.incrementing return Self { uuid() } } }
— 57:41
And then we can register this dependency with the library: extension DependencyValues { var stackElementID: StackElementIDGenerator { get { self[StackElementIDGenerator.self] } set { self[StackElementIDGenerator.self] = newValue } } }
— 58:15
We will now use this dependency in our StackState type instead of the \.uuid dependency: struct StackState<Element> { … @Dependency(\.stackElementID) var stackElementID … init<S: Sequence>(_ elements: S) where S.Element == Element { self.elements = IdentifiedArray( uncheckedUniqueElements: elements.map { Component(id: self.stackElementID(), element: $0) } ) } … mutating func append(_ element: Element) { self.elements.append( Component( id: self.stackElementID(), element: element ) ) } }
— 58:40
With those few small changes everything compiles and the full test suite passes, but now we don’t need to override the UUID dependency because it’s no longer even being used.
— 59:23
Now the thing that really stands out to be as being noisy with all the UUID wrappers everywhere. Technically the user doesn’t even need to be aware of the fact that we are using UUIDs under the hood.
— 59:35
So, what if we abstract the stack element ID a bit by wrapping a private UUID in a new struct: struct StackElementID: Hashable { fileprivate let uuid: UUID }
— 59:53
And this is the type that the StackElementIDGenerator dependency will deal with: struct StackElementIDGenerator { let next: () -> StackElementID func callAsFunction() -> StackElementID { self.next() } }
— 1:00:01
We just have a few small things to update, such as the live and test implementations: extension StackElementIDGenerator: DependencyKey { static let liveValue = Self { StackElementID(uuid: UUID()) } static var testValue: StackElementIDGenerator { let uuid = UUIDGenerator.incrementing return Self { StackElementID(uuid: uuid()) } } }
— 1:00:15
And we’ll need to update the StackState to speak the language of StackElementID : struct StackState<Element> { fileprivate var mounted: Set<StackElementID> = [] … fileprivate struct Component: Identifiable { let id: StackElementID var element: Element } var ids: OrderedSet<StackElementID> { … } @discardableResult mutating func pop(from id: StackElementID) -> Bool { … } subscript(id id: StackElementID) -> Element? { … } … }
— 1:00:45
Which means the navigation link initializer needs to be updated: extension NavigationLink where Destination == Never { init<Element>( state element: Element, @ViewBuilder label: () -> Label ) { self.init( value: StackState<Element>.Component( id: StackElementID(uuid: UUID()), element: element ), label: label ) } }
— 1:00:55
And the StackAction enum: enum StackAction<State, Action> { case element(id: StackElementID, action: Action) case push(State) case popFrom(id: StackElementID) }
— 1:01:06
Now the main application compiles, and it should run exactly as it did before, but tests do not compile. That is just because our test code is littered with references to UUIDs which do not make sense anymore. We need to construct StackElementID s, which is going to be even more verbose than it was before: $0.path[id: StackElementID(uuid: UUID(0))] = .counter()
— 1:01:29
But that actually doesn’t even compile because we keep the UUID field fileprivate and so the synthesized initializer is also fileprivate .
— 1:01:36
We don’t want to open up that initializer to everyone, and this just gives us an opportunity to make this syntax much nicer. We can conform StackElementID to the ExpressibleByIntegerLiteral protocol so that we get a very quick and lightweight way to generate element IDs: extension StackElementID: ExpressibleByIntegerLiteral { init(integerLiteral value: Int) { self.uuid = UUID(value) } }
— 1:02:11
Further, this initializer is only meant to be used in test contexts where we need to explicitly worry about the generational ID of our features in a navigation stack. When our features are running in a live environment, such as a simulator or device, then we should never invoke this initializer because it makes it all too easy to create duplicate IDs, which can cause problems.
— 1:02:31
So, we will further perform a runtime warning if you ever access it in a non-testing environment: import XCTestDynamicOverlay extension StackElementID: ExpressibleByIntegerLiteral { init(integerLiteral value: Int) { @Dependency(\.context) var context if context != .test { XCTFail("This should only be called in tests.") } self.uuid = UUID(value) } }
— 1:02:55
And with that done we now have a really easy way to generate StackElementID s in tests: func testBasics() async { let store = TestStore( initialState: RootFeature.State(), reducer: RootFeature() ) await store.send(.path(.push(.counter()))) { $0.path[id: 0] = .counter() } await store.send(.path(.popFrom(id: 0))) { $0.path = StackState() } }
— 1:03:05
In fact, we can just replace all occurrences of UUID(0) with 0 in this file, as well as UUID(1) and UUID(2) . Just like that everything is compiling, and the test suite still passes.
— 1:03:29
But now tests have gotten much more concise and ergonomic. When the application runs in the simulator or on device it will be generating full-blown random UUIDs under the hood. But, in tests, we can just think of navigation stack IDs as simple, auto-incrementing integer IDs.
— 1:03:56
In fact, it is best to think of these IDs as being generational. Meaning, they forever count up and never go back down. That means if you want to test something simple such as pushing a feature on, popping, and then pushing it again, the newest feature will be using ID 1 instead of 0: func testGenerationalIDs() async { let store = TestStore( initialState: RootFeature.State(), reducer: RootFeature() ) await store.send(.path(.push(.counter()))) { $0.path[id: 0] = .counter() } await store.send(.path(.popFrom(id: 0))) { $0.path = StackState() } await store.send(.path(.push(.counter()))) { $0.path[id: 1] = .counter() } await store.send(.path(.popFrom(id: 1))) { $0.path = StackState() } }
— 1:05:01
And if you push and pop another feature you will be at ID 2: await store.send(.path(.push(.counter()))) { $0.path[id: 2] = .counter() } await store.send(.path(.popFrom(id: 2))) { $0.path = StackState() }
— 1:05:18
This is a nice balance between fully unique identifiers for each feature, which is very safe to use but not very ergonomic, and positional-based indices, which are very ergonomic but not safe at all to use. Here we get to use simple integers for the indices, but they represent the “generation” of the feature being pushed onto the stack. Conclusion
— 1:05:36
And after an incredible 16 episodes we are finally at the end of our Composable Architecture navigation series. We didn’t plan on it being this long when we first embarked on this journey, but a long the way we kept finding little tidbits and improvements we just knew we had to cover, and we hope everyone has enjoyed the ride. Brandon
— 1:05:54
We are also excited to announce that as of today, all of the navigation tools we have covered in the past 16 weeks are now officially apart of the library, and there are even some tools that were added that we didn’t have time to cover in episodes.
— 1:06:07
But now everyone can update their version of the library to the newest release, and you will immediately have all of the tools you need to model your application’s navigation domain, including both tree-based and stack-based navigation schemes.
— 1:06:25
Until next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0237-composable-navigation-pt16 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 .