Flutter Riverpod Testing – Example

Testing your Widget is important during Application Development. When it grows more complex, it becomes more work to test everything manually. Having tests for your widgets makes sure that the Widgets behave as we expect them to. This saves time when developing new features. In the last blog post, we already showed how to do this with Flutter Hooks. In this blog post, we are testing our Flutter Riverpod example.

Setup the project

Before we can start with coding, we will add a dependency to the project, namely Flutter Hooks and Flutter Riverpod. Furthermore, we make sure the Flutter Test SDK is added to the development dependencies. Finally, during the test, we will also use Mockito, which is the last dependency we will add.

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.18.0
  hooks_riverpod: ^1.0.0-dev.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.0.11

Do not forget to install the dependency, running the following command:

flutter pub get

Taking a look at the code

The code we are going to test is the category selector we describe in another blog post. We will go quickly through the code as we have made some changes. First, we have changed the string value to a category. Now we can add a color to each category.

CategoryList createCategoryList(List<Category> values) {
  final Map<Category, bool> categories = Map<Category, bool>();
  values.forEach((value) {
    categories.putIfAbsent(value, () => false);
  });
  return CategoryList(categories);
}

class Category {
  final String name;
  final Color color;

  Category(this.name, this.color);
}

class CategoryList extends StateNotifier<Map<Category, bool>> {
  CategoryList(Map<Category, bool> state) : super(state);

  void toggle(Category item) {
    final currentValue = state[item];
    if (currentValue != null) {
      state[item] = !currentValue;
      state = state;
    }
  }
}

The providers have remained mostly the same. Except, we added a ScopedProvider to use the same CategoryWidget in both the SelectedCategories Widget as the CategoryFilter Widget.

final categoryListProvider = StateNotifierProvider((_) => createCategoryList([
      Category("Apple", Colors.red[700]!),
      Category("Orange", Colors.orange[700]!),
      Category("Banana", Colors.yellow[700]!)
    ]));

final selectedCategories = Provider((ref) => ref
    .watch(categoryListProvider)
    .entries
    .where((MapEntry<Category, bool> category) => category.value)
    .map((e) => e.key)
    .toList());

final allCategories =
    Provider((ref) => ref.watch(categoryListProvider).keys.toList());

final selectedCategory = Provider<Category?>((ref) => null);

We have now created a CategoryWidget. Therefore we can display the categories in both Widgets identically. We can access the category through a provider. So we have to override the provider with the current category.

class CategoryFilter extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final selectedCategoryList = ref.watch(selectedCategories);
    final categoryList = ref.watch(allCategories);

    final notifier = ref.watch(categoryListProvider.notifier);
    return Flexible(
      child: ListView.builder(
          itemCount: categoryList.length,
          itemBuilder: (BuildContext context, int index) {
            return CheckboxListTile(
              value: selectedCategoryList.contains(categoryList[index]),
              onChanged: (bool? selected) {
                notifier.toggle(categoryList[index]);
              },
              title: ProviderScope(overrides: [
                selectedCategory.overrideWithValue(categoryList[index])
              ], child: CategoryWidget()),
            );
          }),
    );
  }
}

class SelectedCategories extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final categoryList = ref.watch(selectedCategories);
    return Flexible(
      child: ListView.builder(
          itemCount: categoryList.length,
          itemBuilder: (BuildContext context, int index) {
            return Padding(
                padding: const EdgeInsets.all(8.0),
                child: ProviderScope(overrides: [
                  selectedCategory.overrideWithValue(categoryList[index])
                ], child: CategoryWidget()));
          }),
    );
  }
}

For displaying the categories in the filter in the same way, we apply the same trick. To toggle the categories, we use the context.read(categoryListProvider).toggle(categoryList[index]); to update the current state of the categories

class CategoryFilter extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final selectedCategoryList = useProvider(selectedCategories);
    final categoryList = useProvider(allCategories);

    return Flexible(
      child: ListView.builder(
          itemCount: categoryList.length,
          itemBuilder: (BuildContext context, int index) {
            return CheckboxListTile(
              value: selectedCategoryList.contains(categoryList[index]),
              onChanged: (bool selected) {
                context.read(categoryListProvider).toggle(categoryList[index]);
              },
              title: ProviderScope(overrides: [
                selectedCategory.overrideWithValue(categoryList[index])
              ], child: CategoryWidget()),
            );
          }),
    );
  }
}

Writing our first test

For our first test, we are going to test the interaction of the Widgets. First, to set up a Widget, we have to wrap it with the HookBuilder and the ProviderScope. Now we can test the Widget as it were a normal Widget. With the findWidgets, we can find the CategoryWidgets and validate the state. Afterward, we click on one of the Widgets. The category is selected and visible in the SelectedCategories.

  testWidgets('Simple interaction test without mocking or overriding',
      (tester) async {
    await tester
        .pumpWidget(ProviderScope(child: HookBuilder(builder: (context) {
      return MaterialApp(home: MultipleCategorySelection());
    })));

    var categories = tester.widgetList(find.byType(CategoryWidget));
    expect(categories.length, 3);

    expect((tester.firstWidget(find.byType(Text)) as Text).style.color,
        Colors.red[700]);
    expect((tester.firstWidget(find.byType(Text)) as Text).data, "Apple");

    var checkboxes = tester.widgetList(find.byType(Checkbox));
    expect(checkboxes.length, 3);

    await tester.tap(find.byWidget(checkboxes.first));
    await tester.pump();

    categories = tester.widgetList(find.byType(CategoryWidget));
    expect(categories.length, 4);

    checkboxes = tester.widgetList(find.byType(Checkbox));
    expect(checkboxes.length, 3);

    await tester.tap(find.byWidget(checkboxes.first));
    await tester.pump();

    categories = tester.widgetList(find.byType(CategoryWidget));
    expect(categories.length, 3);
  });

Overriding the Current Scope

Sometimes, you might want to override the providers. For instance, when you want to test a specific category. In this case, we can override the ScopedProvider with a value. Then, in the ProviderScope, we can choose which provider to override and choose which value to override.

  testWidgets('Override the current scope', (tester) async {
    await tester.pumpWidget(ProviderScope(
        overrides: [
          selectedCategory.overrideWithValue(Category("Pear", Colors.green))
        ],
        child: HookBuilder(builder: (context) {
          return MaterialApp(home: CategoryWidget());
        })));

    expect((tester.firstWidget(find.byType(Text)) as Text).style.color,
        Colors.green);
    expect((tester.firstWidget(find.byType(Text)) as Text).data, "Pear");
  });

Mocking the Notifier and State

When your application becomes more complex, you might want to override the provider. For instance, when your application calls an endpoint to load the initial categories. Luckily we can override the provider. In this case, we must also remember to override the value for the state. Of course, we can also mock the state in more complex situations. In our case, we can instantiate a map and put our desired values in there.

class MockStateNotifier extends Mock implements CategoryList {}  

testWidgets('Verify there are two category widgets', (tester) async {
    final mockStateNotifier = MockStateNotifier();
    final state = Map<Category, bool>();
    state.putIfAbsent(Category("Pear", Colors.green), () => true);
    await tester.pumpWidget(ProviderScope(
        overrides: [
          categoryListProvider.overrideWithValue(mockStateNotifier),
          categoryListProvider.state.overrideWithValue(state),
        ],
        child: HookBuilder(builder: (context) {
          return MaterialApp(home: MultipleCategorySelection());
        })));

    expect((tester.firstWidget(find.byType(Checkbox)) as Checkbox).value, true);

    expect((tester.firstWidget(find.byType(Text)) as Text).style.color,
        Colors.green);
    expect((tester.firstWidget(find.byType(Text)) as Text).data, "Pear");

    var categories = tester.widgetList(find.byType(CategoryWidget));
    expect(categories.length, 2);
  });

We will verify the MockStateNotifier is indeed called, just like we would do with any mocked class. With verify(mockStateNotifier.toggle(category)).called(1); are going to validate the the method is called. We can use the same method to return values from the methods or throw exceptions when the StateNotifier is called.

  testWidgets( 'Verify toggle is called', (tester) async {
    final mockStateNotifier = MockStateNotifier();
    final state = Map<Category, bool>();
    final category = Category("Pear", Colors.green);
    state.putIfAbsent(category, () => true);
    await tester.pumpWidget(ProviderScope(
        overrides: [
          categoryListProvider.overrideWithValue(mockStateNotifier),
          categoryListProvider.state.overrideWithValue(state),
        ],
        child: HookBuilder(builder: (context) {
          return MaterialApp(home: MultipleCategorySelection());
        })));

    var checkbox = tester.widgetList(find.byType(Checkbox));
    await tester.tap(find.byWidget(checkbox.first));
    await tester.pump();

    verify(mockStateNotifier.toggle(category)).called(1);
  });

As always, the code is shared on Github. Thank you for reading, and if you have any suggestions, comments, or remarks. Feel free to leave a comment.

Leave a Reply