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

Testable State Management: Effects

Side effects are by far the hardest thing to test in an application. They speak to the outside world and they tend to be sprinkled around to get the job done. However, we can get broad test coverage of our reducer’s effects with very little work, and it will all be thanks to a simple technique we covered in the past.

This episode builds on concepts introduced previously:

#83 • Monday Dec 2, 2019 • Subscriber-only

Testable State Management: Effects

Side effects are by far the hardest thing to test in an application. They speak to the outside world and they tend to be sprinkled around to get the job done. However, we can get broad test coverage of our reducer’s effects with very little work, and it will all be thanks to a simple technique we covered in the past.

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

This is basically an integration test for reducers. We are testing multiple layers of features, understanding how they interact with each other, and making assertions that they play nicely together. This is pretty huge! Again this is only a toy app, but in a large application you would be able to test that lots of tiny, reusable components continue working properly when they are plugged together. This is already powerful, and we haven’t even discussed effects yet.

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. In the episode, we asserted against testSaveButtonTapped’s save effect by introducing a didSave boolean variable, which initializes false and toggles to true in the environment-controlled effect.

    While this tests that the effect executes, it does not test that the proper data was fed into the effect in the first place. In fact, it ignores this argument entirely!

    Strengthen this test so that it asserts that the correct data was sent to the effect. This will give us test coverage on the JSON encoding logic that is currently in the reducer.

    Solution
    func testSaveButtonTapped() {
      Current = .mock
      var encodedPrimes: Data?
      Current.fileClient.save = { _, data in
        .fireAndForget {
          encodedPrimes = data
        }
      }
    
      var state = [2, 3, 5, 7]
      let effects = favoritePrimesReducer(state: &state, action: .saveButtonTapped)
    
      XCTAssertEqual(state, [2, 3, 5, 7])
      XCTAssertEqual(effects.count, 1)
    
      effects[0].sink { _ in XCTFail() }
    
      XCTAssertEqual(encodedPrimes, try JSONEncoder().encode([2, 3, 5, 7]))
    }
    
  2. Our testLoadButtonTapped passed even when we fed multiple actions back from a single effect.

    Strengthen this test to ensure that only a single action is ever fed back into the reducer.

    Solution

    There are many ways of asserting that only a single value is received, including introducing a mutable tally of how many times receivedValue was called. A simple solution, though, is to introduce a receivedValue expectation and fulfill it in the receivedValue block:

    func testLoadFavoritePrimesFlow() {
      Current.fileClient.load = { _ in .sync { try! JSONEncoder().encode([2, 31]) } }
    
      var state = [2, 3, 5, 7]
      var effects = favoritePrimesReducer(state: &state, action: .loadButtonTapped)
    
      XCTAssertEqual(state, [2, 3, 5, 7])
      XCTAssertEqual(effects.count, 1)
    
      var nextAction: FavoritePrimesAction!
      let receivedCompletion = self.expectation(description: "receivedCompletion")
      let receivedValue = self.expectation(description: "receivedValue")
      effects[0].sink(
        receiveCompletion: { _ in
          receivedCompletion.fulfill()
      },
        receiveValue: { action in
          receivedValue.fulfill()
          XCTAssertEqual(action, .loadedFavoritePrimes([2, 31]))
          nextAction = action
      })
      self.wait(for: [receivedValue, receivedCompletion], timeout: 0)
    
      effects = favoritePrimesReducer(state: &state, action: nextAction)
    
      XCTAssertEqual(state, [2, 31])
      XCTAssert(effects.isEmpty)
    }
    

    If receivedValue is fulfilled more than once, it will cause the test to fail.

  3. Create an alternative instance of FileClient that saves its data to UserDefaults instead of the file system.

    Solution
    extension FileClient {
      static let userDefaults = FileClient(
        load: { fileName -> Effect<Data?> in
          .sync {
            UserDefaults.standard.data(forKey: "FileClient:\(fileName)")
          }
      },
        save: { fileName, data in
          return .fireAndForget {
            UserDefaults.standard.set(data, forKey: "FileClient:\(fileName)")
          }
      })
    }
    
  4. One problem with using an environment struct in each module is that it does not play nicely with sharing dependencies across module boundaries. For example, suppose another module needed a FileClient. You would have no choice but to have two FileClients alive in your application, one for each module, which means you would need to remember to mock both when you want to write tests.

    One way to fix this is to bake the notion of “ennvironment” directly into the reducer signature. Try this out by updating Reducer to be the following shape:

    typealias Reducer<Value, Action, Environment> =
      (inout Value, Action, Environment) -> [Effect<Action>]
    

    This gives you access to the environment in a reducer, which means you can use it to construct effects to be returned. However, this will cause a lot of compiler errors, and to get everything in working order here are some things to work through:

    • The pullback operation needs to be updated to work with this new reducer signature. Is it necessary to pullback along key paths like was done for state and actions, or will a plain function suffice?
    • The Store class needs to be updated since it holds onto a reducer and that has signature has changed. One way to fix this would be to introduce an Environment generic. However, the environment isn’t actually use in its public API, which means maybe we can hide this detail from the type using type erasure.
    • Update modules that use an environment to include their environment directly in the reducer and remove the Current globals.
    Solution

    This will be a future Point-Free episode 😂.


References

Chapters
Introduction
00:05
Testing effects
00:44
Recap: the environment
02:36
Controlling the favorite primes save effect
07:44
Controlling the favorite primes load effect
17:06
Testing the favorite primes save effect
24:19
Testing the favorite primes load effect
31:31
Controlling the counter effect
37:51
Testing the counter effects
42:25
Next time: test ergonomics
48:51