Skip to content

Commit

Permalink
Add TestStore.bindings for testing bindable view state (#2394)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
stephencelis and mbrandonw authored Aug 22, 2023
1 parent 5ba73d2 commit d22ed09
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,6 @@
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "GameSwiftUITests"
BuildableName = "GameSwiftUITests"
BlueprintName = "GameSwiftUITests"
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
Expand All @@ -68,16 +58,6 @@
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "LoginSwiftUITests"
BuildableName = "LoginSwiftUITests"
BlueprintName = "LoginSwiftUITests"
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
Expand All @@ -88,16 +68,6 @@
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "NewGameSwiftUITests"
BuildableName = "NewGameSwiftUITests"
BlueprintName = "NewGameSwiftUITests"
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
Expand All @@ -108,16 +78,6 @@
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TwoFactorSwiftUITests"
BuildableName = "TwoFactorSwiftUITests"
BlueprintName = "TwoFactorSwiftUITests"
ReferencedContainer = "container:tic-tac-toe">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ While the most common way of interacting with a test store's state is via its
also access it directly throughout a test.

- ``state``
- ``bindings``
- ``bindings(action:)``

### Deprecations

Expand Down
76 changes: 74 additions & 2 deletions Sources/ComposableArchitecture/TestStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1861,8 +1861,76 @@ extension TestStore {
}
}

/// The type returned from ``TestStore/send(_:assert:file:line:)`` that represents the lifecycle of
/// the effect started from sending an action.
extension TestStore {
/// 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(.view(.set(\.$email, "blob@pointfree.co"))) {
/// $0.email = "blob@pointfree.co"
/// }
/// XCTAssertTrue(
/// LoginView.ViewState(store.bindings(action: /LoginFeature.Action.view))
/// .isLoginButtonDisabled
/// )
///
/// await store.send(.view(.set(\.$password, "whats-the-point?"))) {
/// $0.password = "blob@pointfree.co"
/// $0.isFormValid = true
/// }
/// XCTAssertFalse(
/// LoginView.ViewState(store.bindings(action: /LoginFeature.Action.view))
/// .isLoginButtonDisabled
/// )
/// ```
///
/// - Parameter toViewAction: A case path from action to a bindable view action.
/// - Returns: A binding view store.
public func bindings<ViewAction: BindableAction>(
action toViewAction: CasePath<Action, ViewAction>
) -> BindingViewStore<State> 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<State> {
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:
///
Expand Down Expand Up @@ -2073,6 +2141,10 @@ class TestReducer<State, Action>: Reducer {
let file: StaticString
let line: UInt

fileprivate var action: Action {
self.origin.action
}

enum Origin {
case receive(Action)
case send(Action)
Expand Down
139 changes: 139 additions & 0 deletions Tests/ComposableArchitectureTests/BindableStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<State>)
case loginButtonTapped
}
var body: some ReducerOf<Self> {
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<LoginFeature.State>) {
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<State>)
case loginButtonTapped
}
}
var body: some ReducerOf<Self> {
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<LoginFeature.State>) {
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
)
}
}

0 comments on commit d22ed09

Please sign in to comment.