felangel / bloc

A predictable state management library that helps implement the BLoC design pattern
https://bloclibrary.dev
MIT License
11.8k stars 3.39k forks source link

[Question] How to correctly test inter-BLoC interactions #1975

Closed wujek-srujek closed 3 years ago

wujek-srujek commented 3 years ago

NOTE: The main, test and pubspec.yaml files are attached: sources.zip.

I have a simple BLoC called EnvBloc which yields new 'environments' whenever certain events happen. I also have another BLoC called CalculationBloc which can perform a calculation within a certain environment (the calculation may yield a few partial updates before finishing with a success). It listens to EnvBloc and if it discovers that the environment changed, it allows the current calculation to finish (which works by default to do asyncExpand in transformEvents), then resets itself and subsequently allows new calculation requests. The listening part is implemented like this in the constructor:

class CalculationBloc extends Bloc<CalculationEvent, CalculationState> {
  CalculationBloc(EnvBloc envBloc) : super(CalculationInitial()) {
    _envSubscription = envBloc.listen((envState) {
      if (envState is EnvChangeSuccess) {
        add(CalculationReset());
      }
    });
  }
...

Now I would like to test this the following way:

  1. CalculationBloc gets CalculationRequested and yields some states.
  2. EnvBloc sends it a EnvChangeSuccess state, and CalculationBloc should reset itself. This is implemented with a mixture of a mock and a StreamController (EnvBloc in this question's sources is trivial but in reality it isn't and I really need to use a mock instead of a real BLoC).
  3. A new CalculationRequested is sent and CalculationBloc yields some more states.
  4. I can verify that all the expected states have been yielded.

I'm trying to do it using the blocTest helper, but I'm failing. The sequence in act is:

bloc.add(CalculationRequested());
envStreamController.sink.add(EnvChangeSuccess());
bloc.add(CalculationRequested());

but the state change in the env always comes last, i.e. my test behaves as if first two CalculationRequested events came, and only then the EnvChangeSuccess. I guess it has something to do with how Futures scheduling works in Dart, could you maybe explain what is going on if you know it?

I also tried to implement a test using the standard test function and await expectLater(...) but it also fails.

How can I test scenarios like this one?

felangel commented 3 years ago

Hi @wujek-srujek πŸ‘‹ Thanks for opening an issue!

The issue is the EnvChangeSuccess is processed before the CalculationRequested event is added but the resulting CalculationReset event isn't added until after. You can address that by waiting until the EnvChangeSuccess has added the CalculationReset event and emitted a new state before adding the second CalculationRequested event:

blocTest<CalculationBloc, CalculationState>(
    'calculation is reset when env changes and can be restarted',
    build: () => calculationBloc,
    act: (bloc) async {
      bloc.add(CalculationRequested());
      envStreamController.sink.add(EnvChangeSuccess());
      await bloc.first;
      bloc.add(CalculationRequested());
    },
    expect: [
      CalculationUpdate(0),
      CalculationUpdate(1),
      CalculationUpdate(2),
      CalculationSuccess(3),
      // At this point, calculation is reset due to env change ...
      CalculationInitial(),
      // ... and subsequently it is restarted again due to a request.
      CalculationUpdate(0),
      CalculationUpdate(1),
      CalculationUpdate(2),
      CalculationSuccess(3),
    ],
  );

In general, I would break this test up and just have one test which ensures that the state is reset in response to an environment change and another test to ensure that the CalculationRequested event yields the correct states.

Hope that helps πŸ‘

wujek-srujek commented 3 years ago

Hi Felix, yes, I would break the test too, but it's hard - we are still at version 2.x.x of bloc and bloc_test (don't ask) and the seed parameter is not yet available there for blocTest, so it is hard to start the test at a certain state.

felangel commented 3 years ago

@wujek-srujek I completely understand. Let me know if there's anything else I can do to help πŸ‘

wujek-srujek commented 3 years ago

I forgot - thank you very much for your help, yet again.

wujek-srujek commented 3 years ago

Hi Felix, Just one more question if I may. You wrote: EnvChangeSuccess is processed before the CalculationRequested event is added but the resulting CalculationReset event isn't added until after and it made me think. Is this because of asynchronicity and microtasks in Dart? I decided to take a deeper dive and my take on what happens in the test is:

  1. According to https://api.flutter.dev/flutter/dart-async/StreamController/add.html, when an event is added to a StreamController, the listener of the stream is called in a 'later microtask'. It doesn't say exactly when this microtask should execute, it just says that it happens later.
  2. New versions of bloc use plain StreamControllers under the hood. Older versions used rxdart's PublishSubject for events and BehaviorSubject for states, but they both wrap StreamController, so the rules still apply.
  3. When the following code is executed:
calculationBloc.add(firstCalculationEvent); // schedules microtask #1
environmentBloc.add(environmentChangeState); // schedules microtask #2
calculationBloc.add(secondCalculationEvent); // schedules microtask #3

3 microtasks are scheduled and are processed in the scheduling sequence (i.e. #1, then #2, then #3), resulting in:

In the application with a real EnvironmentBloc the situation is very similar, it's just that microtask #2 will be more complex as EnvironmentBloc will get it at the event stream end and (similar to microtask #1 and #3 in CalculationBloc) will trigger all the BLoC event to state transformation logic.

The solution with await bloc.first (actually, any Future works) changes the sequence of events in that anything after the await happens in a later event loop iteration, after any microtasks, causing the events taking place in a 'expected' sequence (from the point of view of my test).

According to this understanding described above, I successfully implemented multiple solutions with Futures (including yours) and scheduleMicrotask.

Can you confirm that my understanding of what happens is correct?

felangel commented 3 years ago

@wujek-srujek yes your understanding is correct and in this case adding the environmentChangeState (microtask 2) results in adding the CalculationReset in a later microtask (microtask 4) unless you delay adding the secondCalculationEvent (either using futures or scheduleMicrotask) πŸ‘