From d22ed0942f2c756047f68bf9872e496acf6a5305 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 22 Aug 2023 11:17:55 -0700 Subject: [PATCH] Add `TestStore.bindings` for testing bindable view state (#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 <135203+mbrandonw@users.noreply.github.com> * Update Tests/ComposableArchitectureTests/BindableStoreTests.swift Co-authored-by: Brandon Williams <135203+mbrandonw@users.noreply.github.com> * wip --------- Co-authored-by: Brandon Williams <135203+mbrandonw@users.noreply.github.com> --- .../xcshareddata/xcschemes/TicTacToe.xcscheme | 40 ----- .../Extensions/TestStore.md | 2 + .../ComposableArchitecture/TestStore.swift | 76 +++++++++- .../BindableStoreTests.swift | 139 ++++++++++++++++++ 4 files changed, 215 insertions(+), 42 deletions(-) diff --git a/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToe.xcscheme b/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToe.xcscheme index fb111e464e62..aab026122dcd 100644 --- a/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToe.xcscheme +++ b/Examples/TicTacToe/TicTacToe.xcodeproj/xcshareddata/xcschemes/TicTacToe.xcscheme @@ -48,16 +48,6 @@ ReferencedContainer = "container:tic-tac-toe"> - - - - - - - - - - - - - - - - ( + action toViewAction: CasePath + ) -> BindingViewStore where State == ViewAction.State { + BindingViewStore( + store: Store(initialState: self.state) { + BindingReducer(action: toViewAction.extract(from:)) + } + .scope(state: { $0 }, action: toViewAction.embed) + ) + } +} + +extension TestStore where Action: BindableAction, State == Action.State { + /// Returns a binding view store for this store. + /// + /// Useful for testing view state of a store. + /// + /// ```swift + /// let store = TestStore(LoginFeature.State()) { + /// Login.Feature() + /// } + /// await store.send(.set(\.$email, "blob@pointfree.co")) { + /// $0.email = "blob@pointfree.co" + /// } + /// XCTAssertTrue(LoginView.ViewState(store.bindings).isLoginButtonDisabled) + /// + /// await store.send(.set(\.$password, "whats-the-point?")) { + /// $0.password = "blob@pointfree.co" + /// $0.isFormValid = true + /// } + /// XCTAssertFalse(LoginView.ViewState(store.bindings).isLoginButtonDisabled) + /// ``` + /// + /// - Returns: A binding view store. + public var bindings: BindingViewStore { + self.bindings(action: .self) + } +} + +/// The type returned from ``TestStore/send(_:assert:file:line:)-1ax61`` that represents the +/// lifecycle of the effect started from sending an action. /// /// You can use this value in tests to cancel the effect started from sending an action: /// @@ -2073,6 +2141,10 @@ class TestReducer: Reducer { let file: StaticString let line: UInt + fileprivate var action: Action { + self.origin.action + } + enum Origin { case receive(Action) case send(Action) diff --git a/Tests/ComposableArchitectureTests/BindableStoreTests.swift b/Tests/ComposableArchitectureTests/BindableStoreTests.swift index 298b3c7ed196..c15956bc37c2 100644 --- a/Tests/ComposableArchitectureTests/BindableStoreTests.swift +++ b/Tests/ComposableArchitectureTests/BindableStoreTests.swift @@ -80,4 +80,143 @@ final class BindableStoreTests: XCTestCase { } } } + + func testTestStoreBindings() async { + struct LoginFeature: Reducer { + struct State: Equatable { + @BindingState var email = "" + public var isFormValid = false + public var isRequestInFlight = false + @BindingState var password = "" + } + enum Action: Equatable, BindableAction { + case binding(BindingAction) + case loginButtonTapped + } + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + state.isFormValid = !state.email.isEmpty && !state.password.isEmpty + return .none + case .loginButtonTapped: + state.isRequestInFlight = true + return .none // NB: Login request + } + } + } + } + + struct LoginViewState: Equatable { + @BindingViewState var email: String + var isFormDisabled: Bool + var isLoginButtonDisabled: Bool + @BindingViewState var password: String + + init(_ store: BindingViewStore) { + self._email = store.$email + self.isFormDisabled = store.isRequestInFlight + self.isLoginButtonDisabled = !store.isFormValid || store.isRequestInFlight + self._password = store.$password + } + } + + let store = TestStore(initialState: LoginFeature.State()) { + LoginFeature() + } + await store.send(.set(\.$email, "blob@pointfree.co")) { + $0.email = "blob@pointfree.co" + } + XCTAssertFalse(LoginViewState(store.bindings).isFormDisabled) + XCTAssertTrue(LoginViewState(store.bindings).isLoginButtonDisabled) + await store.send(.set(\.$password, "blob123")) { + $0.password = "blob123" + $0.isFormValid = true + } + XCTAssertFalse(LoginViewState(store.bindings).isFormDisabled) + XCTAssertFalse(LoginViewState(store.bindings).isLoginButtonDisabled) + await store.send(.loginButtonTapped) { + $0.isRequestInFlight = true + } + XCTAssertTrue(LoginViewState(store.bindings).isFormDisabled) + XCTAssertTrue(LoginViewState(store.bindings).isLoginButtonDisabled) + } + + func testTestStoreBindings_ViewAction() async { + struct LoginFeature: Reducer { + struct State: Equatable { + @BindingState var email = "" + public var isFormValid = false + public var isRequestInFlight = false + @BindingState var password = "" + } + enum Action: Equatable { + case view(View) + enum View: Equatable, BindableAction { + case binding(BindingAction) + case loginButtonTapped + } + } + var body: some ReducerOf { + BindingReducer(action: /Action.view) + Reduce { state, action in + switch action { + case .view(.binding): + state.isFormValid = !state.email.isEmpty && !state.password.isEmpty + return .none + case .view(.loginButtonTapped): + state.isRequestInFlight = true + return .none // NB: Login request + } + } + } + } + + struct LoginViewState: Equatable { + @BindingViewState var email: String + var isFormDisabled: Bool + var isLoginButtonDisabled: Bool + @BindingViewState var password: String + + init(_ store: BindingViewStore) { + self._email = store.$email + self.isFormDisabled = store.isRequestInFlight + self.isLoginButtonDisabled = !store.isFormValid || store.isRequestInFlight + self._password = store.$password + } + } + + let store = TestStore(initialState: LoginFeature.State()) { + LoginFeature() + } + await store.send(.view(.set(\.$email, "blob@pointfree.co"))) { + $0.email = "blob@pointfree.co" + } + XCTAssertFalse( + LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isFormDisabled + ) + XCTAssertTrue( + LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isLoginButtonDisabled + ) + await store.send(.view(.set(\.$password, "blob123"))) { + $0.password = "blob123" + $0.isFormValid = true + } + XCTAssertFalse( + LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isFormDisabled + ) + XCTAssertFalse( + LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isLoginButtonDisabled + ) + await store.send(.view(.loginButtonTapped)) { + $0.isRequestInFlight = true + } + XCTAssertTrue( + LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isFormDisabled + ) + XCTAssertTrue( + LoginViewState(store.bindings(action: /LoginFeature.Action.view)).isLoginButtonDisabled + ) + } }