Skip to content

Commit d22ed09

Browse files
Add TestStore.bindings for testing bindable view state (pointfreeco#2394)
* Add `TestStore.bindings` for testing bindable view state Because `@BindingViewState` is populated by a live store, there is no way to easily test `ViewState` structs that use `@BindingViewState`. This adds a `bindings` property on `TestStore` (`bindings(action:)` method when using view actions) that makes it possible to write assertions against view state. * Update Tests/ComposableArchitectureTests/BindableStoreTests.swift Co-authored-by: Brandon Williams <[email protected]> * Update Tests/ComposableArchitectureTests/BindableStoreTests.swift Co-authored-by: Brandon Williams <[email protected]> * wip --------- Co-authored-by: Brandon Williams <[email protected]>
1 parent 5ba73d2 commit d22ed09

File tree

4 files changed

+215
-42
lines changed

4 files changed

+215
-42
lines changed

Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToe.xcscheme

-40
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,6 @@
4848
ReferencedContainer = "container:tic-tac-toe">
4949
</BuildableReference>
5050
</TestableReference>
51-
<TestableReference
52-
skipped = "NO">
53-
<BuildableReference
54-
BuildableIdentifier = "primary"
55-
BlueprintIdentifier = "GameSwiftUITests"
56-
BuildableName = "GameSwiftUITests"
57-
BlueprintName = "GameSwiftUITests"
58-
ReferencedContainer = "container:tic-tac-toe">
59-
</BuildableReference>
60-
</TestableReference>
6151
<TestableReference
6252
skipped = "NO">
6353
<BuildableReference
@@ -68,16 +58,6 @@
6858
ReferencedContainer = "container:tic-tac-toe">
6959
</BuildableReference>
7060
</TestableReference>
71-
<TestableReference
72-
skipped = "NO">
73-
<BuildableReference
74-
BuildableIdentifier = "primary"
75-
BlueprintIdentifier = "LoginSwiftUITests"
76-
BuildableName = "LoginSwiftUITests"
77-
BlueprintName = "LoginSwiftUITests"
78-
ReferencedContainer = "container:tic-tac-toe">
79-
</BuildableReference>
80-
</TestableReference>
8161
<TestableReference
8262
skipped = "NO">
8363
<BuildableReference
@@ -88,16 +68,6 @@
8868
ReferencedContainer = "container:tic-tac-toe">
8969
</BuildableReference>
9070
</TestableReference>
91-
<TestableReference
92-
skipped = "NO">
93-
<BuildableReference
94-
BuildableIdentifier = "primary"
95-
BlueprintIdentifier = "NewGameSwiftUITests"
96-
BuildableName = "NewGameSwiftUITests"
97-
BlueprintName = "NewGameSwiftUITests"
98-
ReferencedContainer = "container:tic-tac-toe">
99-
</BuildableReference>
100-
</TestableReference>
10171
<TestableReference
10272
skipped = "NO">
10373
<BuildableReference
@@ -108,16 +78,6 @@
10878
ReferencedContainer = "container:tic-tac-toe">
10979
</BuildableReference>
11080
</TestableReference>
111-
<TestableReference
112-
skipped = "NO">
113-
<BuildableReference
114-
BuildableIdentifier = "primary"
115-
BlueprintIdentifier = "TwoFactorSwiftUITests"
116-
BuildableName = "TwoFactorSwiftUITests"
117-
BlueprintName = "TwoFactorSwiftUITests"
118-
ReferencedContainer = "container:tic-tac-toe">
119-
</BuildableReference>
120-
</TestableReference>
12181
</Testables>
12282
</TestAction>
12383
<LaunchAction

Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ While the most common way of interacting with a test store's state is via its
3535
also access it directly throughout a test.
3636

3737
- ``state``
38+
- ``bindings``
39+
- ``bindings(action:)``
3840

3941
### Deprecations
4042

Sources/ComposableArchitecture/TestStore.swift

+74-2
Original file line numberDiff line numberDiff line change
@@ -1861,8 +1861,76 @@ extension TestStore {
18611861
}
18621862
}
18631863

1864-
/// The type returned from ``TestStore/send(_:assert:file:line:)`` that represents the lifecycle of
1865-
/// the effect started from sending an action.
1864+
extension TestStore {
1865+
/// Returns a binding view store for this store.
1866+
///
1867+
/// Useful for testing view state of a store.
1868+
///
1869+
/// ```swift
1870+
/// let store = TestStore(LoginFeature.State()) {
1871+
/// Login.Feature()
1872+
/// }
1873+
/// await store.send(.view(.set(\.$email, "[email protected]"))) {
1874+
/// $0.email = "[email protected]"
1875+
/// }
1876+
/// XCTAssertTrue(
1877+
/// LoginView.ViewState(store.bindings(action: /LoginFeature.Action.view))
1878+
/// .isLoginButtonDisabled
1879+
/// )
1880+
///
1881+
/// await store.send(.view(.set(\.$password, "whats-the-point?"))) {
1882+
/// $0.password = "[email protected]"
1883+
/// $0.isFormValid = true
1884+
/// }
1885+
/// XCTAssertFalse(
1886+
/// LoginView.ViewState(store.bindings(action: /LoginFeature.Action.view))
1887+
/// .isLoginButtonDisabled
1888+
/// )
1889+
/// ```
1890+
///
1891+
/// - Parameter toViewAction: A case path from action to a bindable view action.
1892+
/// - Returns: A binding view store.
1893+
public func bindings<ViewAction: BindableAction>(
1894+
action toViewAction: CasePath<Action, ViewAction>
1895+
) -> BindingViewStore<State> where State == ViewAction.State {
1896+
BindingViewStore(
1897+
store: Store(initialState: self.state) {
1898+
BindingReducer(action: toViewAction.extract(from:))
1899+
}
1900+
.scope(state: { $0 }, action: toViewAction.embed)
1901+
)
1902+
}
1903+
}
1904+
1905+
extension TestStore where Action: BindableAction, State == Action.State {
1906+
/// Returns a binding view store for this store.
1907+
///
1908+
/// Useful for testing view state of a store.
1909+
///
1910+
/// ```swift
1911+
/// let store = TestStore(LoginFeature.State()) {
1912+
/// Login.Feature()
1913+
/// }
1914+
/// await store.send(.set(\.$email, "[email protected]")) {
1915+
/// $0.email = "[email protected]"
1916+
/// }
1917+
/// XCTAssertTrue(LoginView.ViewState(store.bindings).isLoginButtonDisabled)
1918+
///
1919+
/// await store.send(.set(\.$password, "whats-the-point?")) {
1920+
/// $0.password = "[email protected]"
1921+
/// $0.isFormValid = true
1922+
/// }
1923+
/// XCTAssertFalse(LoginView.ViewState(store.bindings).isLoginButtonDisabled)
1924+
/// ```
1925+
///
1926+
/// - Returns: A binding view store.
1927+
public var bindings: BindingViewStore<State> {
1928+
self.bindings(action: .self)
1929+
}
1930+
}
1931+
1932+
/// The type returned from ``TestStore/send(_:assert:file:line:)-1ax61`` that represents the
1933+
/// lifecycle of the effect started from sending an action.
18661934
///
18671935
/// You can use this value in tests to cancel the effect started from sending an action:
18681936
///
@@ -2073,6 +2141,10 @@ class TestReducer<State, Action>: Reducer {
20732141
let file: StaticString
20742142
let line: UInt
20752143

2144+
fileprivate var action: Action {
2145+
self.origin.action
2146+
}
2147+
20762148
enum Origin {
20772149
case receive(Action)
20782150
case send(Action)

Tests/ComposableArchitectureTests/BindableStoreTests.swift

+139
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,143 @@ final class BindableStoreTests: XCTestCase {
8080
}
8181
}
8282
}
83+
84+
func testTestStoreBindings() async {
85+
struct LoginFeature: Reducer {
86+
struct State: Equatable {
87+
@BindingState var email = ""
88+
public var isFormValid = false
89+
public var isRequestInFlight = false
90+
@BindingState var password = ""
91+
}
92+
enum Action: Equatable, BindableAction {
93+
case binding(BindingAction<State>)
94+
case loginButtonTapped
95+
}
96+
var body: some ReducerOf<Self> {
97+
BindingReducer()
98+
Reduce { state, action in
99+
switch action {
100+
case .binding:
101+
state.isFormValid = !state.email.isEmpty && !state.password.isEmpty
102+
return .none
103+
case .loginButtonTapped:
104+
state.isRequestInFlight = true
105+
return .none // NB: Login request
106+
}
107+
}
108+
}
109+
}
110+
111+
struct LoginViewState: Equatable {
112+
@BindingViewState var email: String
113+
var isFormDisabled: Bool
114+
var isLoginButtonDisabled: Bool
115+
@BindingViewState var password: String
116+
117+
init(_ store: BindingViewStore<LoginFeature.State>) {
118+
self._email = store.$email
119+
self.isFormDisabled = store.isRequestInFlight
120+
self.isLoginButtonDisabled = !store.isFormValid || store.isRequestInFlight
121+
self._password = store.$password
122+
}
123+
}
124+
125+
let store = TestStore(initialState: LoginFeature.State()) {
126+
LoginFeature()
127+
}
128+
await store.send(.set(\.$email, "[email protected]")) {
129+
$0.email = "[email protected]"
130+
}
131+
XCTAssertFalse(LoginViewState(store.bindings).isFormDisabled)
132+
XCTAssertTrue(LoginViewState(store.bindings).isLoginButtonDisabled)
133+
await store.send(.set(\.$password, "blob123")) {
134+
$0.password = "blob123"
135+
$0.isFormValid = true
136+
}
137+
XCTAssertFalse(LoginViewState(store.bindings).isFormDisabled)
138+
XCTAssertFalse(LoginViewState(store.bindings).isLoginButtonDisabled)
139+
await store.send(.loginButtonTapped) {
140+
$0.isRequestInFlight = true
141+
}
142+
XCTAssertTrue(LoginViewState(store.bindings).isFormDisabled)
143+
XCTAssertTrue(LoginViewState(store.bindings).isLoginButtonDisabled)
144+
}
145+
146+
func testTestStoreBindings_ViewAction() async {
147+
struct LoginFeature: Reducer {
148+
struct State: Equatable {
149+
@BindingState var email = ""
150+
public var isFormValid = false
151+
public var isRequestInFlight = false
152+
@BindingState var password = ""
153+
}
154+
enum Action: Equatable {
155+
case view(View)
156+
enum View: Equatable, BindableAction {
157+
case binding(BindingAction<State>)
158+
case loginButtonTapped
159+
}
160+
}
161+
var body: some ReducerOf<Self> {
162+
BindingReducer(action: /Action.view)
163+
Reduce { state, action in
164+
switch action {
165+
case .view(.binding):
166+
state.isFormValid = !state.email.isEmpty && !state.password.isEmpty
167+
return .none
168+
case .view(.loginButtonTapped):
169+
state.isRequestInFlight = true
170+
return .none // NB: Login request
171+
}
172+
}
173+
}
174+
}
175+
176+
struct LoginViewState: Equatable {
177+
@BindingViewState var email: String
178+
var isFormDisabled: Bool
179+
var isLoginButtonDisabled: Bool
180+
@BindingViewState var password: String
181+
182+
init(_ store: BindingViewStore<LoginFeature.State>) {
183+
self._email = store.$email
184+
self.isFormDisabled = store.isRequestInFlight
185+
self.isLoginButtonDisabled = !store.isFormValid || store.isRequestInFlight
186+
self._password = store.$password
187+
}
188+
}
189+
190+
let store = TestStore(initialState: LoginFeature.State()) {
191+
LoginFeature()
192+
}
193+
await store.send(.view(.set(\.$email, "[email protected]"))) {
194+
$0.email = "[email protected]"
195+
}
196+
XCTAssertFalse(
197+
LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isFormDisabled
198+
)
199+
XCTAssertTrue(
200+
LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isLoginButtonDisabled
201+
)
202+
await store.send(.view(.set(\.$password, "blob123"))) {
203+
$0.password = "blob123"
204+
$0.isFormValid = true
205+
}
206+
XCTAssertFalse(
207+
LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isFormDisabled
208+
)
209+
XCTAssertFalse(
210+
LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isLoginButtonDisabled
211+
)
212+
await store.send(.view(.loginButtonTapped)) {
213+
$0.isRequestInFlight = true
214+
}
215+
XCTAssertTrue(
216+
LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isFormDisabled
217+
)
218+
XCTAssertTrue(
219+
LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isLoginButtonDisabled
220+
)
221+
}
83222
}

0 commit comments

Comments
 (0)