TestStore - mbrandonw/swift-composable-architecture GitHub Wiki

TestStore

DEBUG

A testable runtime for a reducer.

public final class TestStore<State, LocalState, Action: Equatable, LocalAction, Environment>

This object aids in writing expressive and exhaustive tests for features built in the Composable Architecture. It allows you to send a sequence of actions to the store, and each step of the way you must assert exactly how state changed, and how effect emissions were fed back into the system.

There are multiple ways the test store forces you to exhaustively assert on how your feature behaves:

For example, given a simple counter reducer:

struct CounterState {
  var count = 0
}

enum CounterAction: Equatable {
  case decrementButtonTapped
  case incrementButtonTapped
}

let counterReducer = Reducer<CounterState, CounterAction, Void> { state, action, _ in
  switch action {
  case .decrementButtonTapped:
    state.count -= 1
    return .none

  case .incrementButtonTapped:
    state.count += 1
    return .none
  }
}

One can assert against its behavior over time:

class CounterTests: XCTestCase {
  func testCounter() {
    let store = TestStore(
      initialState: .init(count: 0),  // GIVEN counter state of 0
      reducer: counterReducer,
      environment: ()
    )

    store.assert(
      .send(.incrementButtonTapped) { // WHEN the increment button is tapped
        $0.count = 1                  // THEN the count should be 1
      }
    )
  }
}

Note that in the trailing closure of .send(.incrementButtonTapped) we are given a single mutable value of the state before the action was sent, and it is our job to mutate the value to match the state after the action was sent. In this case the count field changes to 1.

For a more complex example, consider the following bare-bones search feature that uses the .debounce operator to wait for the user to stop typing before making a network request:

struct SearchState: Equatable {
  var query = ""
  var results: [String] = []
}

enum SearchAction: Equatable {
  case queryChanged(String)
  case response([String])
}

struct SearchEnvironment {
  var mainQueue: AnySchedulerOf<DispatchQueue>
  var request: (String) -> Effect<[String], Never>
}

let searchReducer = Reducer<SearchState, SearchAction, SearchEnvironment> {
  state, action, environment in

    struct SearchId: Hashable {}

    switch action {
    case let .queryChanged(query):
      state.query = query
      return environment.request(self.query)
        .debounce(id: SearchId(), for: 0.5, scheduler: environment.mainQueue)

    case let .response(results):
      state.results = results
      return .none
    }
}

It can be fully tested by controlling the environment's scheduler and effect:

// Create a test dispatch scheduler to control the timing of effects
let scheduler = DispatchQueue.testScheduler

let store = TestStore(
  initialState: SearchState(),
  reducer: searchReducer,
  environment: SearchEnvironment(
    // Wrap the test scheduler in a type-erased scheduler
    mainQueue: scheduler.eraseToAnyScheduler(),
    // Simulate a search response with one item
    request: { _ in Effect(value: ["Composable Architecture"]) }
  )
)
store.assert(
  // Change the query
  .send(.searchFieldChanged("c") {
    // Assert that state updates accordingly
    $0.query = "c"
  },

  // Advance the scheduler by a period shorter than the debounce
  .do { scheduler.advance(by: 0.25) },

  // Change the query again
  .send(.searchFieldChanged("co") {
    $0.query = "co"
  },

  // Advance the scheduler by a period shorter than the debounce
  .do { scheduler.advance(by: 0.25) },
  // Advance the scheduler to the debounce
  .do { scheduler.advance(by: 0.25) },

  // Assert that the expected response is received
  .receive(.response(["Composable Architecture"])) {
    // Assert that state updates accordingly
    $0.results = ["Composable Architecture"]
  }
)

This test is proving that the debounced network requests are correctly canceled when we do not wait longer than the 0.5 seconds, because if it wasn't and it delivered an action when we did not expect it would cause a test failure.

Initializers

init(initialState:reducer:environment:state:action:)

DEBUG
private init(initialState: State, reducer: Reducer<State, Action, Environment>, environment: Environment, state toLocalState: @escaping (State) -> LocalState, action fromLocalAction: @escaping (LocalAction) -> Action)

Properties

environment

DEBUG
var environment: Environment

fromLocalAction

DEBUG
let fromLocalAction: (LocalAction) -> Action

reducer

DEBUG
let reducer: Reducer<State, Action, Environment>

state

DEBUG
var state: State

toLocalState

DEBUG
let toLocalState: (State) -> LocalState

Methods

scope(state:action:)

DEBUG

Scopes a store to assert against more local state and actions.

public func scope<S, A>(state toLocalState: @escaping (LocalState) -> S, action fromLocalAction: @escaping (A) -> LocalAction) -> TestStore<State, S, Action, A, Environment>

Parameters

  • toLocalState: - toLocalState: A function that transforms the reducer's state into more local state. This state will be asserted against as it is mutated by the reducer. Useful for testing view store state transformations.
  • fromLocalAction: - fromLocalAction: A function that wraps a more local action in the reducer's action. Local actions can be "sent" to the store, while any reducer action may be received. Useful for testing view store action transformations.

scope(state:)

DEBUG

Scopes a store to assert against more local state.

public func scope<S>(state toLocalState: @escaping (LocalState) -> S) -> TestStore<State, S, Action, LocalAction, Environment>

Parameters

  • toLocalState: - toLocalState: A function that transforms the reducer's state into more local state. This state will be asserted against as it is mutated by the reducer. Useful for testing view store state transformations.
⚠️ **GitHub.com Fallback** ⚠️