riverpod
riverpod copied to clipboard
Problem with `ref.listen` - it never invokes in widget test
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.
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 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.
The problem still exists.
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 itsfireImmediately
parameter - call
ref.read
before usingref.listen
to check against the initial state of the provider