riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

Problem with `ref.listen` - it never invokes in widget test

Open abigotado opened this issue 1 year ago • 2 comments

Discussed in https://github.com/rrousselGit/riverpod/discussions/1510

Originally posted by abigotado July 26, 2022 Hello!

I need some help.

Maybe I'm doing something wrong, but I've faced a problem with ref.listen of provider.

I have a StateNotifier and a StateNotifierProvider. And there is a ref.listen() functions in my HookConsumerWidget's build() method. It calls showModalBottomSheet() method on certain (error) state. Everything goes well when I'm running my code. Screen builds, ref.listen works, BottomSheet appears when error occurs - just like expected.

But when I'm running a widget test for the screen - it fails, as far as the BottomSheet never appears - state changes, but ref.listen never invokes. I tried to change my ref.listen - just to make a debugPrint() when state changes. Nothing changes - in the app everything is OK, I see my print in console, but during the test state changes, however ref.listen never invokes. And I cannot find reason for such behavior. Why may it happen?

Code examples.

State
import 'package:freezed_annotation/freezed_annotation.dart';

part 'pagination_controller_state.freezed.dart';

/// State class for pagination screens.
@freezed
class PaginationControllerState<T> with _$PaginationControllerState<T> {
  /// Factory for generating PaginationControllerState.
  const factory PaginationControllerState({
    /// If true, data is loading.
    @Default(false) final bool isLoading,

    /// If true, error has occurred.
    @Default(false) final bool isError,

    /// Page of the list.
    final int? page,

    /// Quantity of pages of the list.
    final int? totalPages,

    /// Error.
    final Object? error,

    /// List of items.
    final List<T>? items,
  }) = _PaginationControllerState<T>;
}
StateNotifier
import 'package:hooks_riverpod/hooks_riverpod.dart';

/// Controller abstraction for pagination screens.
abstract class PaginationController<T>
    extends StateNotifier<PaginationControllerState<T>> {
  /// Creates a PaginationController.
  PaginationController() : super(PaginationControllerState<T>()) {
    fetchList();
  }

  /// If true the last page of the list has been loaded.
  bool get isLastPageLoaded =>
      state.page != null &&
      state.totalPages != null &&
      state.totalPages == state.page;

  /// Fetch list and manage states.
  Future<void> fetchList({final bool isRefresh = false}) async {
    if (!isLastPageLoaded) {
      if (!state.isLoading) {
        state = state.copyWith(
          isLoading: true,
          isError: false,
          error: null,
          page: isRefresh ? null : state.page,
        );
        final int nextPage = (state.page ?? 0) + 1;
        try {
          final PaginationListModel<T>? pagination =
              await getPagination(nextPage);
          state = state.copyWith(
            isLoading: false,
            items: <T>[
              ...state.items ?? <T>[],
              ...pagination?.data ?? <T>[],
            ],
            page: pagination?.meta?.pagination?.page,
            totalPages: pagination?.meta?.pagination?.pages,
          );
        } catch (e) {
          state = state.copyWith(
            isLoading: false,
            isError: true,
            error: e,
          );
        }
      }
    } else {
      state = state.copyWith(
        isLoading: false,
      );
    }
  }

  /// Load pagination from repository.
  Future<PaginationListModel<T>?> getPagination(final int page);
}
import 'package:hooks_riverpod/hooks_riverpod.dart';

/// Provider of the controller of StudyDirectionsScreen states.
final AutoDisposeStateNotifierProviderFamily<StudyDirectionsController,
        PaginationControllerState<StudyDirectionModel>, String?>
    directionsControllerProvider = StateNotifierProvider.autoDispose.family<
        StudyDirectionsController,
        PaginationControllerState<StudyDirectionModel>,
        String?>(
  (
    final AutoDisposeStateNotifierProviderRef<StudyDirectionsController,
            PaginationControllerState<StudyDirectionModel>>
        ref,
    final String? id,
  ) =>
      StudyDirectionsController(ref.watch(directionsRepositoryProvider(id))),
);

/// The controller of StudyDirectionsScreen states.
class StudyDirectionsController
    extends PaginationController<StudyDirectionModel> {
  /// Creates StudyDirectionsController.
  StudyDirectionsController(this.directionsRepository);

  /// Repository with study directions methods and variables.
  final StudyDirectionsRepository directionsRepository;

  @override
  Future<PaginationListModel<StudyDirectionModel>?> getPagination(
    final int page,
  ) {
    return directionsRepository.fetchPagination(page);
  }
}
ref.listen
ref.listen(directionsControllerProvider(id),
        (final PaginationControllerState<StudyDirectionModel>? previous,
            final PaginationControllerState<StudyDirectionModel> next) {
          ref.listen<PaginationControllerState<StudyDirectionModel>>(
            directionsControllerProvider(id),
                (
                final PaginationControllerState<StudyDirectionModel>? previousState,
                final PaginationControllerState<StudyDirectionModel> state,
                ) =>
            state.isError && state.items == null
                ? showModalBottomSheet<ErrorBottomSheet>(
              isScrollControlled: true,
              context: context,
              builder: (final BuildContext context) {
                debugPrint('ErrorBottomSheet is shown');
                return ErrorBottomSheet(
                  errorText: state.error is ServerError
                      ? ((state.error as ServerError?)
                      ?.errors?[0]
                      .detail ??
                      (state.error as ServerError?)?.error ??
                      'Что-то пошло не так')
                      : state.error.toString(),
                  onPressed: () => readDirectionsController.fetchList(isRefresh: true),
                );
              },
            )
                : null,
          );
    });
The test
class MockStudyDirectionsController extends StudyDirectionsController {
  MockStudyDirectionsController(super.directionsRepository);
}

@GenerateMocks(<Type>[GoRouter])
void main() {
  final MockGoRouter router = MockGoRouter();

  final List<StudyDirectionModel> fullData = tDirectionsPagination.data;
  fullData.addAll(tDirectionsPaginationSecondPage.data);

  final MockStudyDirectionsRepository repository =
      MockStudyDirectionsRepository();

  final AutoDisposeStateNotifierProvider<StudyDirectionsController,
          PaginationControllerState<StudyDirectionModel>> controllerProvider =
      StateNotifierProvider.autoDispose<StudyDirectionsController,
          PaginationControllerState<StudyDirectionModel>>(
    (
      final AutoDisposeStateNotifierProviderRef<StudyDirectionsController,
              PaginationControllerState<StudyDirectionModel>>
          ref,
    ) =>
        MockStudyDirectionsController(repository),
  );

  final Widget app = ProviderScope(
    overrides: <Override>[
      directionsControllerProvider(null)
          .overrideWithProvider(controllerProvider),
    ],
    child: ScreenUtilInit(
      designSize: const Size(375, 812),
      builder: (final BuildContext context, final Widget? child) {
        return MaterialApp(
          home: InheritedGoRouter(
            goRouter: router,
            child: const StudyDirectionsScreen(),
          ),
        );
      },
    ),
  );

testWidgets('Test error state', (final WidgetTester tester) async {
      when(repository.fetchPagination(1)).thenThrow(tServerError);

      await mockNetworkImagesFor(() async {
        await tester.pumpWidget(app);

        expect(find.text('Направления обучения'), findsOneWidget);

        expect(find.byKey(const ValueKey<String>('shimmer 0')), findsOneWidget);

        await tester.pump(Duration(seconds: 1));
        await tester.pump(Duration(seconds: 1));
        await tester.pump(Duration(seconds: 1));
        await tester.pump(Duration(seconds: 1));
        await tester.pump(Duration(seconds: 1));
        await tester.pump(Duration(seconds: 1));

        expect(find.text('Что-то пошло не так'), findsOneWidget);

        await tester.tapAt(const Offset(20, 20));

        expect(find.byKey(const ValueKey<String>('shimmer 0')), findsOneWidget);
      });
    });

I've tried to use pumpAndSettle() too.

abigotado avatar Aug 03 '22 09:08 abigotado

Hello!

Could you include a minimal reproducible example instead? I appreciate the code that you gave, but this isn't something I can execute.

rrousselGit avatar Aug 04 '22 13:08 rrousselGit

@rrousselGit Hello, Remi!

Thank you very much for your reply!

I've done my best to detach a problem part from my app and created a reproducible example. Hope it will be helpful!

You can see it here: https://github.com/abigotado/problem_example_riverpod_ref_listen_testing (I've created a repository for this case).

Just clone it and run 'widget_test.dart' to see the problem that I have been describing.

abigotado avatar Aug 10 '22 14:08 abigotado

The problem still exists.

abigotado avatar Nov 12 '22 19:11 abigotado

Sorry for the long delay.

The error appears to be on your side. Your test synchronously throws, so by the time ref.listen is reached, the provider is already is error state.

Either:

  • use listenManual combined with its fireImmediately parameter
  • call ref.read before using ref.listen to check against the initial state of the provider

rrousselGit avatar Mar 09 '24 15:03 rrousselGit