Skip to content

Commit cc52e99

Browse files
authored
Add gotcha for @Shared testing (#3607)
1 parent 05601a7 commit cc52e99

File tree

2 files changed

+110
-0
lines changed

2 files changed

+110
-0
lines changed

Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,3 +1109,74 @@ Alternatively you can take an extra step to override shared state in your previe
11091109
```
11101110

11111111
The second assignment of `isOn` will guarantee that it holds a value of `true`.
1112+
1113+
#### Tests
1114+
1115+
While shared properties are compatible with the Composable Architecture's testing tools, assertions
1116+
may not correspond directly to a particular action when several actions are received by effects.
1117+
1118+
Take this simple example, in which a `tap` action kicks off an effect that returns a `response`,
1119+
which finally mutates some shared state:
1120+
1121+
```swift
1122+
@Reducer
1123+
struct Feature {
1124+
struct State: Equatable {
1125+
@Shared(value: false) var bool
1126+
}
1127+
enum Action {
1128+
case tap
1129+
case response
1130+
}
1131+
var body: some ReducerOf<Self> {
1132+
Reduce { state, action in
1133+
switch action {
1134+
case .tap:
1135+
return .run { send in
1136+
await send(.response)
1137+
}
1138+
case .response:
1139+
state.$bool.withLock { $0.toggle() }
1140+
return .none
1141+
}
1142+
}
1143+
}
1144+
}
1145+
```
1146+
1147+
We would expect to assert against this mutation when the test store receives the `response` action,
1148+
but this will fail:
1149+
1150+
```swift
1151+
// ❌ State was not expected to change, but a change occurred: …
1152+
//
1153+
// Feature.State(
1154+
// - _shared: #1 false
1155+
// + _shared: #1 true
1156+
//   )
1157+
//
1158+
// (Expected: −, Actual: +)
1159+
await store.send(.tap)
1160+
1161+
// ❌ Expected state to change, but no change occurred.
1162+
await store.receive(.response) {
1163+
$0.$shared.withLock { $0 = true }
1164+
}
1165+
```
1166+
1167+
This is due to an implementation detail of the `TestStore` that predates `@Shared`, in which the
1168+
test store eagerly processes all actions received _before_ you have asserted on them. As such, you
1169+
must always assert against shared state mutations in the first action:
1170+
1171+
```swift
1172+
await store.send(.tap) { //
1173+
$0.$shared.withLock { $0 = true }
1174+
}
1175+
1176+
// ❌ Expected state to change, but no change occurred.
1177+
await store.receive(.response) //
1178+
```
1179+
1180+
In a future major version of the Composable Architecture, we will be able to introduce a breaking
1181+
change that allows you to assert against shared state mutations in the action that performed the
1182+
mutation.

Tests/ComposableArchitectureTests/StoreTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,45 @@ final class StoreTests: BaseTCATestCase {
11741174
cancellable.cancel()
11751175
XCTAssertNil(weakStore)
11761176
}
1177+
1178+
@MainActor
1179+
func testSharedMutation() async {
1180+
XCTTODO(
1181+
"""
1182+
Ideally this will pass in 2.0 but it's a breaking change for test stores to not eagerly \
1183+
process all received actions.
1184+
"""
1185+
)
1186+
1187+
let store = TestStore(initialState: TestSharedMutation.State()) {
1188+
TestSharedMutation()
1189+
}
1190+
await store.send(.tap)
1191+
await store.receive(.response) {
1192+
$0.$bool.withLock { $0 = true }
1193+
}
1194+
}
1195+
@Reducer
1196+
struct TestSharedMutation {
1197+
struct State: Equatable {
1198+
@Shared(value: false) var bool
1199+
}
1200+
enum Action {
1201+
case tap
1202+
case response
1203+
}
1204+
var body: some ReducerOf<Self> {
1205+
Reduce { state, action in
1206+
switch action {
1207+
case .tap:
1208+
return .send(.response)
1209+
case .response:
1210+
state.$bool.withLock { $0.toggle() }
1211+
return .none
1212+
}
1213+
}
1214+
}
1215+
}
11771216
}
11781217

11791218
#if canImport(Testing)

0 commit comments

Comments
 (0)