Testing receiving actions of Child features

Hi guys,
I have a situation where we have a TopLevelFeature, which Scopes multiple standalone Child features (FeatureA, FeatureB, FeatureC etc). When sending a .refresh Action to the TopLevelFeature, this calls the reducer for each standalone feature and maps each feature's reload Action like so:

  private func whenRefreshing(_ state: inout State) -> Effect<Action> {
    let featureA = FeatureA().reduce(into: &state.featureA, action: .reload).map(Action.featureA)
    let featureB = FeatureB().reduce(into: &state.featureB, action: .reload).map(Action.featureB)
    let featureC = FeatureC().reduce(into: &state.featureC, action: .reload).map(Action.featureC)
    let featureD = FeatureD().reduce(into: &state.featureD, action: .reload).map(Action.featureD)
    let featureE = FeatureE().reduce(into: &state.featureE, action: .reload).map(Action.featureE)

    return Effect.merge([
      featureA,
      featureB,
      featureC,
      featureD,
      featureE
    ])
  }

Each Child Feature looks like this

@Reducer
struct FeatureA {
  @ObservableState
  struct State: Equatable {
    
  }
  
  enum Action {
    case reload
    case response
  }
  
  var body: some ReducerOf<Self> {
    Reduce<State, Action> { state, action in
      switch action {
      case .reload:
        return someWork()
      case .response:
        return .none
      }
    }
  }
  
  private func someWork() -> Effect<Action> {
    .run { send in
      await send(.response)
    }
  }
}

The code seems to run fine (i.e. Effects run in parallel), but when running tests it has some behavior I was not expecting:

func test() async throws {

  let store = TestStore(
    initialState: TopLevelFeature.State()) {
      TopLevelFeature()
    }

  store.exhaustivity = .off

  await store.send(.refresh)

  await store.receive(\.featureA.response)
  await store.receive(\.featureB.response)
  await store.receive(\.featureC.response)
  await store.receive(\.featureD.response)
  await store.receive(\.featureE.response)
}

Only featureA and featureB pass, the others get a Expected to receive an action matching case path, but didn't get one. test failure.

Changing the order in which they're awaited, or the order in which the Effects are merged in the production code, changes the results of the test.

The are only two instances in which all tests pass:

  1. In the test actions are awaited in exactly reverse order in which they're merged in the production code.
  2. The production code uses a series of nested merge(with: ) (which only merges two Effects into one) and test awaits for the actions in the exact same order (as shown below):
  private func whenRefreshing(_ state: inout State) -> Effect<Action> {
    let featureA = FeatureA().reduce(into: &state.featureA, action: .reload).map(Action.featureA)
    let featureB = FeatureB().reduce(into: &state.featureB, action: .reload).map(Action.featureB)
    let featureC = FeatureC().reduce(into: &state.featureC, action: .reload).map(Action.featureC)
    let featureD = FeatureD().reduce(into: &state.featureD, action: .reload).map(Action.featureD)
    let featureE = FeatureE().reduce(into: &state.featureE, action: .reload).map(Action.featureE)

    return featureA
      .merge(with: featureB
        .merge(with: featureC
          .merge(with: featureD
            .merge(with: featureE ))))
  }

I'm wondering whether there's a mechanism in the TestStore that guarantees that they are awaited in the same order, perhaps to avoid flaky results? But why would it be in reverse order (see option 1 above), or same order but with nested merge(with: )?

There's prob an obvious answer to this but it's eluding me.

What is the way to write a valid test for this type of use case?

Thanks a lot!

Danny

1 Like

Hey @dannyfloww, thanks for bringing this up! It unfortunately does not have an easy explanation or fix.

I don't think it was always this way, but it seems that at some point nested withTaskGroups started executing non-deterministically even when using the global main serial executor. So when you merge a bunch of effects, under the hood that translates to a bunch of nested withTaskGroups, and that is causing the problem.

Funnily enough, if you were to simply merge in a single empty publisher into the chain:

return .merge(
  .publisher { Empty() },
  …
}

…then the emissions become deterministic. This is because when merging effects, once a Combine publisher is encountered we convert all effects to Combine (including async ones) and use Combine operators. And one of the big benefits of Combine is that everything is deterministic.

In the past we have played around with a new receive method on test stores that would allow receiving multiple actions at once in any order and then asserting on the state at the end:

await store.receive(
  \.child1.response,
  \.child2.response,
  \.child3.response,
  \.child4.response,
  \.child5.response
) {
  $0…
}

If this is something you wanted to play around with we'd happily discuss it with you, but we have a few other higher priorities to work on right now.

Also, we do have some upcoming improvements to the library that will make all of this non-determinism go away too. We will no longer need to merge effects into a big, nested withTaskGroup, and so that will fix this problem entirely.

3 Likes