Presenter tests
Last modified on Thu 27 Oct 2022

This is next level of testing which is more complex than interactor or static utilities testing. But presenters contain fair amount of presentation logic, so it is valuable to test them. To understand these tests we will first explain how do you test streams.

Stream testing

When testing streams the key thing to remember are special methods for stream testing like expect(stream, emits(1)). Here the test will check if stream will emit value 1. Important thing is that it does with an equality check, so if you are expecting classes you need to have == and hashCode overriden to work. One option is to use freezed which will generate these for you.

Few other useful methods are:

If your stream emits right away without any async gap, then you will not be able to test it in the next line:

emit1From(stream);
expect(stream, emits(1)) // <-- this fails because 1 was already emitted before this line is executed

For these cases you can use expectLater, which works same as expect but you write it before the method that you test:

expectLater(stream, emits(1))
emit1From(stream);

Stream testing will be often used with presenters, because ChangeNotifier and StreamNotifier both use stream and it’s core of how they work. Apart from that we also use actions (in communication from Presenter → UI) which are also implemented as stream.

ProviderContainer

Since our presenters are provided by riverpod, we will also have to use riverpod in tests. When testing the presenter we will not instantiate it like we did with the Interactors. Reason is that presenters can use ref.read or ref.watch to communicate with other presenters. This functionality of ProviderReference we cannot mock easily, so it’s best to include riverpod into our tests.

When you want to use riverpod in the app you need to add ProviderScope widget. In unit tests, you need to add ProviderContainer class, which manages the creation and referencing of our presenters.

final container = ProviderContainer();
addTearDown(container.dispose); // good practice to add

For example, let’s say we want to test following presenter:

class ProfileFormPresenter extends StateNotifier<ProfileFormViewState> {
  ProfileFormPresenter() : super(ProfileFormViewState(isChecked: false));

  void onToggleClicked() {
    state = state.copyWith(isChecked: !state.isChecked);
  }
}

It’s a simple presenter for form. When we use ProviderContainer we explained, the full test should look like:

test('isChecked is true when toggle is clicked', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    container.read(profileFormPresenter.notifier).onToggleClicked();

    expect(container.read(profileFormPresenter).isChecked, true);
});

We can see that now we don’t instantiate presenter directly, but let the ProviderContainer do it. And we access our presenter with container.read.

Dependency overrides

The parameter overrides inside of the ProviderContainer will let us specify different implementations for other presenters. Since we don’t have an access to presenter constructor anymore, we cannot provide our mock implementations anymore. For this reason you will want to use overrides:

test('weatherPresenter refreshes with cityPresenters new value', () async {
  final container = ProviderContainer(
    overrides: [
      cityPresenter.overrideWithValue(MockCityPresenter()),
    ],
  );
  addTearDown(container.dispose);

  // test
});

Actions

Every Notifier with mixin ActionEmitter exposes action getter on itself which is a stream of actions. With this getter, testing actions becomes simple stream testing that we explained earlier. For example, let's say we have this action and presenter for login screen:

@freezed
class LoginScreenAction with _$LoginScreenAction {
  const factory LoginScreenAction.showSuccess() = _LoginScreenActionShowSuccess;
}

final loginScreenPresenter = StateNotifierProvider.autoDispose<LoginScreenPresenter>(
  (ref) => LoginScreenPresenter(),
);

class LoginScreenPresenter extends StateNotifier<String> with StateNotifierActionEmitterMixin<LoginScreenAction, String> {
  LoginScreenPresenter() : super('');

  void onFieldChanged(String field) {
    state = field;

    if (_validateField(field)) {
      emitAction(const LoginScreenAction.showSuccess());
    }
  }
}

Then test for loginScreenPresenter would look like this:

test('loginScreenPresenter emits showSuccess action', () async {
  final container = ProviderContainer();
  addTearDown(container.dispose);

  expect(
    container.read(loginScreenPresenter.notifier).action,
    emits(const LoginScreenAction.showSuccess()),
  );

  container.read(loginScreenPresenter.notifier).onFieldChanged('valid_field');

  // If loginScreenPresenter doesn't emit LoginScreenAction.showSuccess() then test can go on forever to wait
  // for presenter to emit the action so we need to dispose of it to close the actions stream
  container.read(loginScreenPresenter.notifier).dispose();
});

Presenters can also be tested through widget tests. This way you can see if presenters are connected to widgets properly. For more on that topic go to next chapter widget testing.

Testing the state stream

If we are using StateNotifier then it will be easy to also test the state as a stream.

container.read(weatherPresenter.notifier).changeToImperialSystem();

expectLater(
  container.read(weatherPresenter.notifier).stream,
  emitsInOrder(
    WeatherScreenViewState(temperature: '30 °C', city: 'Palermo'),
    WeatherScreenViewState(temperature: '86 °F', city: 'Palermo'),
  ),
);

Note that this checking of emitted items work by equality check. So if you have a class as a state, then you should override == and hashCode. Since that’s a bit tiresome to write, it’s best to use either equatable or freezed. We recommend freezed because it will also give you copyWith functionality which you often need in StateNotifiers when you want to change only part or one field of the view state.

It’s also possible to test RequestNotifier in a similar way:

expect(
   container.read(weatherPresenter.notifier).stream,
   emits(
     RequestState<WeatherReport>.success(
       WeatherReport(temperature: 1, windSpeed: 1, city: 'Nairobi'),
     ),
   ),
);