A video series exploring functional programming and Swift.
#84 • Monday Dec 9, 2019 • Subscriber-only

Testable State Management: Ergonomics

We not only want our architecture to be testable, but we want it to be super easy to write tests, and perhaps even a joy to write tests! Right now there is a bit of ceremony involved in writing tests, so we will show how to hide away those details behind a nice, ergonomic API.

This episode builds on concepts introduced previously:

#84 • Monday Dec 9, 2019 • Subscriber-only

Testable State Management: Ergonomics

We not only want our architecture to be testable, but we want it to be super easy to write tests, and perhaps even a joy to write tests! Right now there is a bit of ceremony involved in writing tests, so we will show how to hide away those details behind a nice, ergonomic API.

This episode builds on concepts introduced previously:


Subscribe to Point‑Free

This episode is for subscribers only. To access it, and all past and future episodes, become a subscriber today!

See subscription optionsorLog in

Sign up for our weekly newsletter to be notified of new episodes, and unlock access to any subscriber-only episode of your choosing!

Sign up for free episode

Introduction

We have now written some truly powerful tests. Not only are we testing how the state of the application evolves as the user does various things in the UI, but we are also performing end-to-end testing on effects by asserting that the right effect executes and the right action is returned.

We do want to mention that the way we have constructed our environments is not 100% ideal right now. It got the job done for this application, but we will run into problems once we want to share a dependency amongst many independent modules, like say our PrimeModal module wanted access to the FileClient. We’d have no choice but to create a new FileClient instance for that module, which would mean the app has two FileClients floating around. Fortunately, it’s very simple to fix this, and we will be doing that in a future episode really soon.

Another thing that isn’t so great about our tests is that they’re quite unwieldy. Some of the last tests we wrote are over 60 lines! So if we wrote just 10 tests this file would already be over 600 lines.

There is a lot of ceremony in our tests right now. We must:

  • create expectations
  • run the effects
  • wait for expectations
  • fulfill expectations
  • capture the next action
  • assert what action we got and feed it back into the reducer.

That’s pretty intense to have to repeat for every effect we test, and as we mentioned it doesn’t even catch the full story of effects since some extra ones could have slipped in.

Maybe we can focus on the bare essentials: the shape of what we need to do in order to assert expectations against our architecture. It seems to boil down to providing some initial state, providing the reducer we want to test, and then feeding a series of actions and expections along the way, ideally in a declarative fashion with little boilerplate!

Subscribe to Point-Free

👋 Hey there! Does this episode sound interesting? Well, then you may want to subscribe so that you get access to this episodes and more!


Exercises

  1. Extract the assert helper to a ComposableArchitectureTestSupport module that can be imported in all of the test modules.

    Solution
    1. Create a new iOS framework.
    2. Move the test support file into the module’s group (and double check that the file is included in the test support framework target.
    3. If you try to build the test support module, it will fail when it tries to link to XCTest. It’s not well-documented, but with some internet sleuthing you may come across a solution, which is to add -weak-lswiftXCTest as a linker flag to the test module’s build settings.
    4. Add ComposableArchitectureTestSupport as a dependency to all of the test modules that need it.

    You may also need to add the following framework search paths:

    $(DEVELOPER_FRAMEWORKS_DIR) $(PLATFORM_DIR)/Developer/Library/Frameworks
    
  2. Update the prime modal tests to use the assert helper.

    Solution

    PrimeModalState is a tuple, and incompatible with the assert helper because tuples are non-nominal types that cannot conform to protocols, like Equatable. While we could write an overload of assert that supports tuples of state, let’s instead take the opportunity to upgrade the module’s root state value to a proper struct that conforms to Equatable. This requires a little boilerplate of a public initializer.

    public struct PrimeModalState: Equatable {
      public var count: Int
      public var favoritePrimes: [Int]
    
      public init(
        count: Int,
        favoritePrimes: [Int]
      ) {
        self.count = count
        self.favoritePrimes = favoritePrimes
      }
    }
    

    This is enough to write some tests, but let’s make sure the app still builds by fixing the counter module.

    First, we must update CounterViewState’s primeModal property to work with a struct instead of a tuple.

    var primeModal: PrimeModalState {
      get { PrimeModalState(count: self.count, favoritePrimes: self.favoritePrimes) }
      set { (self.count, self.favoritePrimes) = (newValue.count, newValue.favoritePrimes) }
    }
    

    Second, we should delegate to this property when projecting into this state for the view.

    IsPrimeModalView(
      store: self.store
        .view(
          value: { $0.primeModal },
          action: { .primeModal($0) }
      )
    )
    

    We’re finally ready to upgrade our tests! We can even combine them into a single test that exercises saving and removing at once.

    func testSaveAndRemoveFavoritesPrimesTapped() {
      assert(
        initialValue: PrimeModalState(count: 2, favoritePrimes: [3, 5]),
        reducer: primeModalReducer,
        steps:
        Step(.send, .saveFavoritePrimeTapped) {
          $0.favoritePrimes = [3, 5, 2]
        },
        Step(.send, .removeFavoritePrimeTapped) {
          $0.favoritePrimes = [3, 5]
        }
      )
    }
    
  3. Let’s start updating the favorite primes tests to use the assert helper. In this exercise, update testDeleteFavoritePrimes.

    Solution
    func testDeleteFavoritePrimes() {
      assert(
        initialValue: [2, 3, 5, 7],
        reducer: favoritePrimesReducer,
        steps: Step(.send, .deleteFavoritePrimes([2])) {
          $0 = [2, 3, 7]
        }
      )
    }
    
  4. Update testLoadFavoritePrimesFlow to use the assert helper.

    Solution
    func testLoadFavoritePrimesFlow() {
      Current.fileClient.load = { _ in .sync { try! JSONEncoder().encode([2, 31]) } }
    
      assert(
        initialValue: [2, 3, 5, 7],
        reducer: favoritePrimesReducer,
        steps:
        Step(.send, .loadButtonTapped) { _ in },
        Step(.receive, .loadedFavoritePrimes([2, 31])) {
          $0 = [2, 31]
        }
      )
    }
    
  5. Try to update testSaveButtonTapped to use the assert helper. What goes wrong?

    Solution

    We might try to update this test with the following:

    func testSaveButtonTapped() {
      var didSave = false
      Current.fileClient.save = { _, data in
        .fireAndForget {
          didSave = true
        }
      }
    
      assert(
        initialValue: [2, 3, 5, 7],
        reducer: favoritePrimesReducer,
        steps:
        Step(.send(.saveButtonTapped) { _ in })
      )
      XCTAssert(didSave)
    }
    

    But when we run it, it fails:

    ❌ failed - Assertion failed to handle 1 pending effect(s) ❌ XCTAssertTrue failed

    The assert helper only runs effects when it expects to receive an event from one, which means it’s not equipped to handle fire-and-forget logic.

  6. Update the assert helper to support testing fire-and-forget effects (like the one on testSaveButtonTapped). This will involve changing the way StepType and Step look so that they can describe the idea of fire-and-forget effects that can be handled in assert.

    Solution

    There are a few ways to account for fire-and-forget effects with our test helper. One thing we could do is upgrade StepType with the idea of a step that accounts for a fireAndForget effect.

    enum StepType {
      case send
      case receive
      case fireAndForget
    }
    

    This makes Step a bit more complicated: it has a non-optional action and update function, but neither of these are relevant to fire-and-forget effects because they cannot feed actions back to the system and mutate state.

    We could make the action optional and get things building, but that would allow us to describe some truly nonsensical steps, including:

    • A send step with a nil action
    • A receive step with a nil action
    • A fireAndForget step with an action or a mutation (or both!)

    Let’s use some of the lessons of Algebraic Data Types to refactor Step and StepType to eliminate these impossible states.

    Both send and receive care about the associated data of the action and mutation, while fireAndForget does not. We can push this data deeper into StepType as associated values, and we can nest StepType inside of Step so that it gets access to the Value and Action generics.

    struct Step<Value, Action> {
      enum StepType {
        case send(Action, (inout Value) -> Void)
        case receive(Action, (inout Value) -> Void)
        case fireAndForget
      }
    
      let type: StepType
      let file: StaticString
      let line: UInt
    
      init(
        _ type: StepType,
        file: StaticString = #file,
        line: UInt = #line
      ) {
        self.type = type
        self.file = file
        self.line = line
      }
    }
    

    Now, we must update assert to extract these values and exhaustively switch on fire-and-forget effects.

    func assert<Value: Equatable, Action: Equatable>(
      initialValue: Value,
      reducer: Reducer<Value, Action>,
      steps: Step<Value, Action>...,
      file: StaticString = #file,
      line: UInt = #line
    ) {
      var state = initialValue
      var effects: [Effect<Action>] = []
    
      steps.forEach { step in
        var expected = state
    
        switch step.type {
        case let .send(action, update):
          if !effects.isEmpty {
            XCTFail("Action sent before handling \(effects.count) pending effect(s)", file: step.file, line: step.line)
          }
          effects.append(contentsOf: reducer(&state, action))
          update(&expected)
          XCTAssertEqual(state, expected, file: step.file, line: step.line)
        case let .receive(expectedAction, update):
          guard !effects.isEmpty else {
            XCTFail("No pending effects to receive from", file: step.file, line: step.line)
            break
          }
          let effect = effects.removeFirst()
          var action: Action!
          let receivedCompletion = XCTestExpectation(description: "receivedCompletion")
          _ = effect.sink(
            receiveCompletion: { _ in
              receivedCompletion.fulfill()
          },
            receiveValue: { action = $0 }
          )
          if XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) != .completed {
            XCTFail("Timed out waiting for the effect to complete", file: step.file, line: step.line)
          }
          XCTAssertEqual(action, expectedAction, file: step.file, line: step.line)
          effects.append(contentsOf: reducer(&state, action))
          update(&expected)
          XCTAssertEqual(state, expected, file: step.file, line: step.line)
        case .fireAndForget:
          guard !effects.isEmpty else {
            XCTFail("No pending effects to run", file: step.file, line: step.line)
            break
          }
          let effect = effects.removeFirst()
          let receivedCompletion = XCTestExpectation(description: "receivedCompletion")
          _ = effect.sink(
            receiveCompletion: { _ in
              receivedCompletion.fulfill()
          },
            receiveValue: { _ in XCTFail() }
          )
          if XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) != .completed {
            XCTFail("Timed out waiting for the effect to complete", file: step.file, line: step.line)
          }
        }
      }
      if !effects.isEmpty {
        XCTFail("Assertion failed to handle \(effects.count) pending effect(s)", file: file, line: line)
      }
    }
    
  7. Using the updated assert helper from the previous exercise, rewrite testSaveButtonTapped.

    Solution
    func testSaveButtonTapped() {
      var didSave = false
      Current.fileClient.save = { _, data in
        .fireAndForget {
          didSave = true
        }
      }
    
      assert(
        initialValue: [2, 3, 5, 7],
        reducer: favoritePrimesReducer,
        steps:
        Step(.send(.saveButtonTapped) { _ in }),
        Step(.fireAndForget)
      )
      XCTAssert(didSave)
    }
    

References

Chapters
Introduction
00:05
Simplifying testing state
02:03
The shape of a test
06:50
Improving test feedback
14:10
Trailing closure ergonomics
18:38
Actions sent and actions received
19:17
Assertion edge cases
31:45
Conclusion
35:18
Next time: the point
40:50