Video #271: Shared State: Testing, Part 1
Episode: Video #271 Date: Mar 18, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep271-shared-state-testing-part-1

Description
The @Shared property wrapper can effortlessly share state among features to build complex flows quickly, but because it is powered by a reference type, it is not compatible with the Composable Architecture’s value-oriented testing tools. Let’s address these shortcomings and recover all of the library’s testing niceties.
Video
Cloudflare Stream video ID: 71c1e38b6ce6fcc902bdf21652206033 Local file: video_271_shared-state-testing-part-1.mp4 *(download with --video 271)*
Transcript
— 0:05
And so this is absolutely incredible. We now have the basics of dedicated @Shared property wrapper that allows us to easily share data between multiple features. And we’ve seen very concretely that this allows us to create complex features quite easily.
— 0:20
Now in doing this we did have to come face-to-face with what it means to put a reference type into our state. On the one hand it’s really no different than using a dependency to model shared state. Dependencies are very reference-like, even if they are modeled as structs, and so we used that fact to justify using a reference type directly in state. And thanks to Swift’s observation tools, reference types are now observable, and so everything just worked really nicely. Stephen
— 0:43
However, what is not going to be so nice about everything we have done so far is testing. Reference types are notoriously difficult to test because they are an amalgamation of data and behavior, and because they can’t be copied. This makes it difficult to compare the data inside a reference before and after a mutation has occurred so that we can assert on how it changed in an easy and exhaustive manner.
— 1:06
Let’s see these problems concretely, and then see how we might fix them. The problem with testing
— 1:11
In order to see the problem with testing we are going to go back to our shared state case study since it’s quite a bit simpler than the sign up flow we built a moment ago.
— 1:19
Let’s run this first test, and somehow it actually passes. This is surprising because we are currently asserting that when changing a tab all that happens is the currentTab state mutates: func testTabSelection() async { let store = TestStore( initialState: SharedState.State(stats: Shared(Stats())) ) { SharedState() } await store.send(.selectTab(.profile)) { $0.currentTab = .profile } await store.send(.selectTab(.counter)) { $0.currentTab = .counter } }
— 1:35
But we currently we have the extra logic that increments the stats when switching tabs: case let .selectTab(tab): state.currentTab = tab state.stats.increment() return .none
— 1:42
So the test is passing even though we are not asserting on that logic.
— 1:48
We can add that logic to the assertion: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.stats.increment() } await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.stats.increment() }
— 1:58
And the test is still passing.
— 2:01
But we can also increment a bunch of times: $0.stats.increment() $0.stats.increment() $0.stats.increment()
— 2:06
…and the test still passes.
— 2:09
In order to see what is truly happening in these references we need to add some explicit XCAssertEqual s after the send s just so that we can confirm these stats references are in the state we expect: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.stats.increment() $0.stats.increment() $0.stats.increment() } XCTAssertEqual(store.state.stats.count, 1) await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.stats.increment() $0.stats.increment() $0.stats.increment() } XCTAssertEqual(store.state.stats.count, 2)
— 2:35
Unfortunately those assertions fail: XCTAssertEqual failed: (“4”) is not equal to (“1”) XCTAssertEqual failed: (“8”) is not equal to (“2”)
— 2:41
Well, clearly the stats is not what we expect, yet somehow the test store assertions pass. How is that happening?
— 2:47
The way this send method works is that it captures the current state in a local variable, then it passes that local copy to the trailing closure so that we can mutate it, and then it compares that mutated local copy to the true state of the feature after sending the action and letting the reducer process the state.
— 3:03
However, since the state holds a reference, each of the copies of state created are all secretly holding onto the same reference. So it doesn’t matter if we make two copies of state and mutate the stats in one of the copies. In reality that is mutating the reference, which exists in both copies, and hence it seems as if both copies have been mutated.
— 3:20
So we never get to truly see the state of the feature before and after the action is sent. With references there really is no before and after. There is only now since references are not copyable.
— 3:29
So, by leaving the world of value types for shared state and embracing a reference type in our domain, we have sadly given up exhaustive testing. At least as far as the shared state is concerned. All of the other state in the feature must exhaustively be asserted on, such as the currentTab .
— 3:50
So, the correct way to test this now is to not mutate the shared state at all inside the trailing closure of store.send , and instead just assert on the state after sending the action: await store.send(.selectTab(.profile)) { $0.currentTab = .profile } XCTAssertEqual(store.state.stats.count, 1) await store.send(.selectTab(.counter)) { $0.currentTab = .counter } XCTAssertEqual(store.state.stats.count, 2)
— 4:04
But also there’s a lot more data inside Stats than just a count, so if we want to be more comprehensive we can assert on all of the state: await store.send(.selectTab(.profile)) { $0.currentTab = .profile } XCTAssertEqual( store.state.stats, Stats( count: 1, maxCount: 1, minCount: 0, numberOfCounts: 1 ) ) await store.send(.selectTab(.counter)) { $0.currentTab = .counter } XCTAssertEqual( store.state.stats, Stats( count: 2, maxCount: 2, minCount: 0, numberOfCounts: 2 ) )
— 4:30
But we must always remember to manually assert on this data. The test store no longer has our back in automatically and exhaustively asserting against all changes to state.
— 4:38
If we run the second test it fails, but the failures are a little strange. All of the failures say: Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is expected, omit the trailing closure.
— 4:51
This is telling us that we provided a trailing closure to describe how state changed, but we didn’t actually make any changes to the state in the closure. That is, the $0 handed to the closure matches exactly what $0 was at the end of the closure.
— 5:03
But we clearly are performing mutations to $0 right? Like here: await store.send(.counter(.incrementButtonTapped)) { $0.counter.stats.increment() $0.profile.stats.increment() }
— 5:08
Well, again, this is due to the shared references we have in our state. We are mutating the reference , but that equality of that reference is not taken into account for the equality of the feature’s state. So, as far as the test store is concerned, state did not change.
— 5:19
And so again we must assert on the stats object as a separate process from asserting on the state changes, and we must delete the trailing closure entirely: await store.send(.counter(.incrementButtonTapped)) XCTAssertEqual( store.state.stats, Stats( count: 1, maxCount: 1, minCount: 0, numberOfCounts: 1 ) ) await store.send(.counter(.decrementButtonTapped)) XCTAssertEqual( store.state.stats, Stats( count: 0, maxCount: 1, minCount: 0, numberOfCounts: 2 ) )
— 5:45
And we have to do this again when testing the reset functionality: await store.send(.profile(.resetStatsButtonTapped)) XCTAssertEqual( store.state.stats, Stats() )
— 5:53
And now we have a passing test suite.
— 5:58
It is a bit of a bummer that our test suite has suffered just because we put a reference type in our state. We now have to be more mindful of how shared state is used in our features and make sure to assert on it where necessary. But the TestStore can no longer help us do that. How to test shared state
— 6:09
So it seems we have a pretty serious trade off to consider when using shared state.
— 6:13
On the one hand it allowed us to create a very complex feature, as we did with the sign up flow in the last episode, but on the other hand it completely destroys our ability to exhaustively test our features, which is one of the best parts of the Composable Architecture.
— 6:26
And that’s just the reality of using reference types. They can be powerful, but they also introduce a lot of uncertainty and complexity into an application. And it’s why the Composable Architecture has historically favored value types over reference types. Brandon
— 6:38
But what if we told you that it is actually possible to exhaustively test shared state. That it’s possible to share state between multiple features with a reference, and then write a test that exhaustively proves your features mutate state in the way you expect. Even though a reference type is being used under the hood.
— 6:56
It sounds too good to be true, but it is true, and so let’s take a look.
— 7:01
First of all if we run the test suite right now everything is passing, so that’s nice. What’s not so nice is that it is on us to explicitly assert against the shared state outside of the send trailing closure: await store.send(.selectTab(.profile)) { $0.currentTab = .profile } XCTAssertEqual( store.state.stats, Stats( count: 1, maxCount: 1, minCount: 0, numberOfCounts: 1 ) )
— 7:17
We can’t make these assertions inside the trailing closure because although $0 is the state before sending the action, it still holds the shared reference that has the freshest data no matter what, and so there is no way to see the before and after: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.stats.increment() }
— 7:34
Or is there?
— 7:36
After all, the Composable Architecture is a very closed system, and in the past that has allowed us to exercise a ton of super powers. It’s what allows us to implement a simple and easy-to-use dependency system, it allows us to create incredible debugging tools such as the _printChanges reducer operator, exhaustive testing, and a lot more. Those things are only possible due to the fact that at its root there is a single reducer and store powering a feature.
— 8:02
What if we could somehow snapshot all of the shared state just before the TestStore runs its reducer, and then we could compare that snapshot with the final value? Luckily we have a dedicated Shared type that generically wraps a value, and so that seems like the natural place to hold the snapshot: @Observable @propertyWrapper final class Shared<Value> { var wrappedValue: Value private var snapshot: Value? … }
— 8:30
We are going to make the snapshot optional so that we do not always incur the cost of having a snapshot around. It should only be populated and used in tests.
— 8:43
And now we can start performing some magic. When the wrappedValue is mutated we can try to detect if we are inside a TestStore assertion in order to provide custom snapshotting logic. To do this we need to be able to tap into the get and set of wrappedValue , which means it needs to be a computed property that delegates down to some private stored property: @Observable @propertyWrapper final class Shared<Value> { var wrappedValue: Value { get { self.currentValue } set { self.currentValue = newValue } } private var currentValue: Value private var snapshot: Value? init(_ value: Value) { self.currentValue = value } … }
— 9:34
And now we can do some fanciness. We need to be able to detect if we are in a TestStore assertion right here, but we want to do so in an unobtrusive way. One way to do this would be for the TestStore to set and unset some kind of global boolean when performing assertions, and then we could check that global in here. But, globals are of course gnarly to work with since they don’t respect lexical scopes and you have to do extra work to make them thread safe.
— 10:07
Luckily Swift provides a really wonderful tool for dealing with globals in a way that is both thread safe and lexical scope safe, and that’s TaskLocal s. Task locals are even what powers our Dependencies library under the hood.
— 10:21
Let’s create a task local that can be set from the outside to communicate to the Shared type that an assertion is currently being made: enum SharedLocals { @TaskLocal static var isAsserting = false }
— 10:36
And then in the wrappedValue setter we can check this flag in order to have custom setting logic: set { if SharedLocals.isAsserting { } else { } }
— 10:48
If this boolean is true, which means we are currently executing the trailing closure of TestStore.send , then we should not make mutations to the currentValue but rather the snapshot . This is because the snapshot should hold the version of the value from before sending the action, and so that is the value we want to mutate to get it to match the current value: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { } }
— 11:18
And then when not asserting, meaning we are mutating this value outside of a test store assertion, we want to mutate the actual current value: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { self.currentValue = newValue } }
— 11:29
But we need an additional trick. We want to snapshot the current value before making the mutation, but also we only want to do it for the first mutation: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue } }
— 12:17
And there’s one last trick to employ, and that is how we compare two Shared objects for equality. Current we are just delegating down to the underlying wrapped value: extension Shared: Equatable where Value: Equatable { public static func == ( lhs: Shared, rhs: Shared ) -> Bool { lhs.wrappedValue == rhs.wrappedValue } }
— 12:28
This of course is not good for tests because the lhs and rhs will always be the same reference, and hence the underlying wrapped values will be the same.
— 12:45
But now that we have the snapshot value inside Shared we can pretend as if we have two different objects. When we are in a TestStore assertion we will define equality as being between the snapshot and the current value, and otherwise just compare the current value: extension Shared: Equatable where Value: Equatable { static func == (lhs: Shared, rhs: Shared) -> Bool { if SharedLocals.isAsserting { return lhs.snapshot == rhs.currentValue } else { return lhs.currentValue == rhs.currentValue } } }
— 12:59
It’s tricky, but that little bit of code will get us very far.
— 13:27
We now need to update the TestStore so that it can broadcast when its in the middle of an assertion so that the Shared type can do its magic. But to do that we now need to move the Shared type into the Composable Architecture module, which is where it belongs anyway since its library code.
— 13:59
So, let’s create a new Shared.swift file.
— 14:07
And we’ll cut and paste all Shared code into that file. And we have to make everything public.
— 14:22
And since the Composable Architecture has much older platform requirements we have to mark the class as @Perceptible , not @Observable : import Perception @Perceptible @dynamicMemberLookup public final class Shared<Value> { … }
— 14:38
Now we need to wrap the test store assertion code inside a task local so that we can set that isAsserting flag to true . Luckily there is only one place in the test store that performs state assertions, and it’s in a private helper called expectedStateShouldMatch . We can write the body of this function in the task local’s withValue so that we can change the local just for the lifetime of a lexical scope: private func expectedStateShouldMatch( expected: State, actual: State, updateStateToExpectedResult: ((inout State) throws -> Void)? = nil, skipUnnecessaryModifyFailure: Bool = false, file: StaticString, line: UInt ) throws { try SharedLocals.$isAsserting.withValue(true) { … } }
— 15:56
And with that bit of upfront work we can already see some fruits of our labor. If we run the test suite again we will now see some failures where previously there were none: await store.send(.selectTab(.profile)) { $0.currentTab = .profile } This is a failure we want because we have not asserted on how the stats state changes. We are now being forced to assert on that data even though it’s shared and locked up in a reference type.
— 16:13
The test failure message isn’t fantastic, but it does show us some interesting things: A state change does not match expectation: … SharedState.State( _currentTab: .profile, _counter: CounterTab.State( _alert: nil, // Not equal but no difference detected: − _stats: Shared(…) + _stats: Shared(…) ), _profile: ProfileTab.State( − _stats: Shared(↩︎) + _stats: Shared(↩︎) ), − _stats: Shared(↩︎) + _stats: Shared(↩︎) ) (Expected: −, Actual: +) It correctly points out the 3 places we are using shared stats state, but it’s not able to tell us what differs in the state. And the comment in the message lets us know why: // Not equal but no difference detected:
— 16:29
The library detected that two objects are not equal, because the double equal check == returned false, but in reality those two objects are the same reference. And so when the library tries to generate a diff of the objects, there is nothing to show because, well, they are the same object!
— 17:00
There is something we can do to improve this situation. If you didn’t know already, our CustomDump library is what powers the fantastic error messages in the Composable Architecture. It is a library that can print out a well-formatted string description of a deeply nested value, and using that well-formatted description it can even show a nicely formatted diff between two values.
— 17:21
But, the library is mostly tuned to work with value types. Dumping the data in a class and diffing two objects of a class is a little strange due to the behavior and reference semantics in classes. But, the library does provide a tool to make it somewhat possible, especially for situations like we are running into now.
— 17:42
We can conform our Shared class to a special protocol that the CustomDump library provides, called _CustomDiffObject : extension Shared: _CustomDiffObject { }
— 18:07
And its one requirement is to provide the “before” and “after” value representation of the object: extension Shared: _CustomDiffObject { public var _customDiffValues: (Any, Any) { } }
— 18:19
Since references don’t have a “before” and “after”, this protocol allows us to emulate that where appropriate. And then the library will use this before and after values in order to print a nicely formatted diff.
— 18:40
Our before is the snapshot , or if there is none the currentValue , and the after is the currentValue : extension Shared: _CustomDiffObject { public var _customDiffValues: (Any, Any) { (self.snapshot ?? self.currentValue, self.currentValue) } }
— 18:50
With that one small change our test failure messages instantly get much better: A state change does not match expectation: … SharedState.State( _currentTab: .profile, _counter: CounterTab.State( _alert: nil, _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) (Expected: −, Actual: +) This is pretty amazing. We are now seeing exactly what differs in our reference type before the action was sent and after it was sent. We can clearly see what stats properties do not match.
— 19:11
To fix this, we just need to increment the stats in each action: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.stats.increment() } await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.stats.increment() }
— 19:28
Unfortunately the test still fails: A state change does not match expectation: … SharedState.State( _currentTab: .profile, _counter: CounterTab.State( _alert: nil, _stats: Stats( − count: 2, + count: 1, − maxCount: 2, + maxCount: 1, minCount: 0, − numberOfCounts: 2 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( − count: 2, + count: 1, − maxCount: 2, + maxCount: 1, minCount: 0, − numberOfCounts: 2 + numberOfCounts: 1 ) ), _stats: Stats( − count: 2, + count: 1, − maxCount: 2, + maxCount: 1, minCount: 0, − numberOfCounts: 2 + numberOfCounts: 1 ) ) (Expected: −, Actual: +)
— 19:31
Now the test store thinks the count is 2 when it should be 1. Previously it thought the count was 0. So somehow by incrementing a single time we have actually incremented twice.
— 19:44
The reason this is happening is because we are performing an incremental mutation to the stats by calling the increment method, rather than perform an absolute mutation by just reassigning the value of stats . If we do the absolute mutation instead: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.stats = Stats( count: 1, maxCount: 1, minCount: 0, numberOfCounts: 1 ) } await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.stats = Stats( count: 2, maxCount: 2, minCount: 0, numberOfCounts: 2 ) }
— 20:09
…the test suite passes.
— 20:11
So this is very promising. We do seem to be getting some exhaustive testing on shared state, but it’s a bummer that it doesn’t seem to play nicely with making incremental mutations.
— 20:44
Luckily this is quite easy to fix.
— 20:47
When setting a new value in the Shared type we have some branching logic based on the isAsserting task local: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue } }
— 21:07
And when performing an in-place, incremental mutation Swift will first invoke the get , perform the mutation on that state, and then perform the set . But the get is simply this right now: get { self.currentValue }
— 21:23
There’s an asymmetry between get and set , where in one we do extra logic to check the isAsserting task local, and the other we do not. That is causing the problem. When we are in an assertion block we should return the snapshot so that the test store can make mutations to that value, rather than the current value: get { if SharedLocals.isAsserting { return self.snapshot ?? self.currentValue } else { return self.currentValue } }
— 22:00
And with that our test with the incremental mutation is passing: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.stats.increment() } await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.stats.increment() }
— 22:09
And technically we could have mutated any of the stats held in our features, such as the counter one: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.counter.stats.increment() }
— 22:26
And this still passes. But this is to be expected because the state is all shared. Testing improvements
— 22:38
I think this is looking absolutely incredible. We are now getting exhaustive testing on reference types. There is just no version of this kind of testing in vanilla SwiftUI. You cannot test observable objects in an exhaustive manner whatsoever. And the only reason we are able to do this is thanks to the closed system of the Composable Architecture, and the powerful TestStore . Stephen
— 22:57
However, there are still a few problems with testing shared state. Let’s continue probing what we have done so far to find the problems, and see how to fix them.
— 23:06
Let’s update the next test, by bringing back the trailing closures and asserting on the single piece of shared state: await store.send(.counter(.incrementButtonTapped)) { $0.stats.increment() } await store.send(.counter(.decrementButtonTapped)) { $0.stats.decrement() } await store.send(.counter(.resetStatsButtonTapped)) { $0.stats = Stats() }
— 23:42
And let’s see if it passes.
— 23:45
It does not unfortunately, and it’s with a failure that is a bit surprising: Expected state to change, but no change occurred. The trailing closure made no observable modifications to state. If no change to state is expected, omit the trailing closure.
— 23:50
This is telling us that we provided a trailing closure to assert on state mutations, but that we didn’t actually mutate something, and so that the trailing closure wasn’t even needed.
— 24:00
But, we are certainly mutating something. We are calling a mutating methods on a value held in a reference. And if we remove the trailing closure we get a different failure: await store.send(.counter(.incrementButtonTapped))
— 24:07
…telling us that the state changed and that we need to assert on it: State was not expected to change, but a change occurred: … SharedState.State( _currentTab: .counter, _counter: CounterTab.State( _alert: nil, _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) (Expected: −, Actual: +)
— 24:11
These failures are sending us mixed signals. First we perform the mutation and it says it didn’t detect a state change, and then we stopped performing the mutation and it told us we assert on the state change.
— 24:22
Why is that? Well the test store does an additional check once it evaluates the trailing closure to ensure that the value before and after the mutation are actually different and that we are actually asserting against a change. This too relies on copying value types to compare them before and after a mutation, but in this test the only mutation that occurs is in a shared reference, and so when the test store compares the values before and after, the references are the same and are even equal since the snapshot is now equal to the current value.
— 24:52
So, we need to do more work in order to omit this error message when none of the non-shared state has changed, but a piece of shared state has changed. This means we need some kind of communication mechanism that goes in the opposite direction than what we have been doing so far.
— 25:06
Right now we allow the TestStore to communicate to the Shared class via the isAsserting boolean: set { if SharedLocals.isAsserting { … } else { … } }
— 25:14
This allows the TestStore to tell the Shared class when it is doing its assertions.
— 25:24
We need something to go in the other direction, so that the Shared class can tell the TestStore that it was mutated, that way the TestStore can know it shouldn’t emit that test failure about no change occurring.
— 25:35
The way to do this is yet another TaskLocal , but this time it will be a closure that the TestStore can provide and that the Shared class can invoke: enum SharedLocals { @TaskLocal static var isAsserting = false @TaskLocal static var changeTracker: @Sendable () -> Void = {} }
— 26:01
This opens up a little communication wormhole between TestStore and Shared .
— 26:05
All Shared has to do is invoke this closure when setting the value, but only when the test store is not asserting: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue SharedLocals.changeTracker() } }
— 26:14
And then in the TestStore we need to set this task local when sending an action so that the shared can communicate to it. We can do this right in the send method of the TestStore , where we wrap sending the action to the underlying store : let task = SharedLocals.$changeTracker.withValue({ // Shared is communicating with us }) { self.store.send( .init(origin: .send(action), file: file, line: line), originatingFrom: nil ) }
— 26:37
We just need to track mutable piece of boolean state so that we know if that closure is ever called: var sharedStateDidChange = false let task = SharedLocals.$changeTracker.withValue({ sharedStateDidChange = true }) { … }
— 26:51
And then we can tell the expectedStateShouldMatch helper that we looked at a moment ago that it should skip emitting any “no change occurred” test failures based on this lock isolated value: try self.expectedStateShouldMatch( expected: expectedState, actual: currentState, updateStateToExpectedResult: updateStateToExpectedResult, skipUnnecessaryModifyFailure: sharedStateDidChange, file: file, line: line )
— 27:09
And just like that, these assertions are all passing: await store.send(.counter(.incrementButtonTapped)) { $0.stats.increment() } await store.send(.counter(.decrementButtonTapped)) { $0.stats.decrement() } await store.send(.profile(.resetStatsButtonTapped)) { $0.stats.reset() }
— 27:17
It’s absolutely incredible to see that not only can we exhaustively test reference types in our state, but we can still keep a lot of the nice little details that the TestStore gives us.
— 27:27
There’s one final test in this file and so let’s run it to see if it passes.
— 27:33
Unfortunately it does not, and that’s surprising because this test only exercises the alert functionality of the counter feature. It doesn’t even mutate any shared state whatsoever.
— 27:45
And the test failure message is not helpful at all: A state change does not match expectation: … SharedState.State( _currentTab: .counter, _counter: CounterTab.State( _alert: AlertState( title: "👎 The number 0 is not prime :(" ), _stats: Stats(…) ), _profile: ProfileTab.State( _stats: Stats(…) ), _stats: Stats(…) ) (Expected: −, Actual: +)
— 27:48
Somehow the TestStore thinks that some state changed, yet when we diff the two values there is no diff to show.
— 27:57
This is happening specifically because we are not making any mutations to the Shared state, and so the underlying snapshot data is not updated ever. In fact, if we just make a no-op mutation like this: await store.send(.counter(.isPrimeButtonTapped)) { $0.stats.reset() $0.counter.alert = AlertState { TextState("👎 The number 0 is not prime :(") } }
— 28:15
Then the test will suddenly pass.
— 28:18
The problem is in the Equatable conformance of Shared . Currently it’s just directly checking the snapshot against the currentValue : extension Shared: Equatable where Value: Equatable { public static func == ( lhs: Shared, rhs: Shared ) -> Bool { if SharedLocals.isAsserting { return lhs.snapshot == rhs.currentValue } else { return lhs.currentValue == rhs.currentValue } } }
— 28:34
But what if the snapshot is nil ? That is the case when no shared state is mutated at all. In that case we can coalesce the snapshot to the current value: return lhs.snapshot ?? lhs.currentValue == rhs.currentValue
— 29:03
And now the test still passes, but we can also get rid of the no-op mutation: await store.send(.counter(.isPrimeButtonTapped)) { $0.counter.alert = AlertState { TextState("👎 The number 0 is not prime :(") } }
— 29:07
…and that test still passes.
— 29:10
So we have now fixed all the edge cases of testing in the Composable Architecture when using shared state. But, there is one thing to not like about how we accomplished this. The snapshot field added to Shared only exists to help with testing, and so ideally it should not be used at all when running in the simulator or on device. However, looking at how it’s used: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue SharedLocals.changeTracker() } } We see that if we are not asserting in the TestStore , that is we are in the else branch of the isAsserting check, then we are going to always snapshot the state upon first mutation.
— 29:39
This is not a very efficient use of state, especially since during normal runs of our applications the snapshot value is not needed at all.
— 29:46
It seems like we need to know when the change tracker is installed so that only then we can make snapshots. If we aren’t tracking changes, then there is no need to squirrel away the snapshot upon first mutation.
— 29:59
We could represent this concept by making the changeTracker optional: @TaskLocal static var changeTracker: (@Sendable () -> Void)? = nil
— 30:08
Then if it’s nil we know a tracker is not installed and so no need to take snapshots. We can even add a computed property to SharedLocals for easily checking if the tracker is installed: static var isTracking: Bool { self.changeTracker != nil }
— 30:22
Now we can update the set of wrappedValue to only snapshot if a tracker is installed, and we need to optionally invoke the changeTracker closure: set { if SharedLocals.isAsserting { self.snapshot = newValue } else { if SharedLocals.isTracking, self.snapshot == nil { self.snapshot = self.currentValue } self.currentValue = newValue SharedLocals.changeTracker?() } }
— 30:38
The app will work exactly as it did before, but now it is more efficient since it will no longer keep a copy of state around when it is not needed. Next time: advanced testing
— 30:49
So, it may not seem like it, but what we have accomplished is actually quite amazing. We are writing tests for features that contain a reference type as if they are just regular features built on value types. We are exhaustively testing every aspect of the feature, while at the same time being able to share a piece of state with multiple features. This means we have all of the ergonomics and powers of reference types, with seemingly none of the down sides. Brandon
— 31:11
But so far the tests we have been dealing with have been quite simple. Let’s see a more real world and advanced example. In fact, the sign up flow we built the previous episode would be a great testing ground for this. That was dealing with state shared amongst many features, and if we can simple tests for that, then we would know we have achieved something pretty amazing.
— 31:32
Let’s take a look…next time! Downloads Sample code 0271-shared-state-pt4 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .