Flutter Riverpod Testing – Example

Testing your Widget is important during Application Development. When it grows more complex, it becomes more work to manually test everything. 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 are going to 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. During the test, we are also going to use Mockito, which is the last dependency we will add.

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.15.0
  hooks_riverpod: ^0.12.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^4.1.4

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. We have changed the string value to a category. Now we can add a color to each category.

CategoryList createCategoryList(List values) {
  final Map categories = Map();
  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> {
  CategoryList(Map state) : super(state);

  void toggle(Category item) {
    state[item] = !state[item];
    state = state;
  }
}

The providers have remained mostly the same. Except, we added a ScopedProvider, so that we can 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.state)
    .entries
    .where((category) => category.value)
    .map((e) => e.key)
    .toList());

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

final selectedCategory = ScopedProvider(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 SelectedCategories extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final categoryList = useProvider(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()));
          }),
    );
  }
}

class CategoryWidget extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final category = useProvider(selectedCategory);
    return Text(
      category.name,
      style: TextStyle(color: category.color),
    );
  }
}

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 setup a Widget, we have to wrap it with the HookBuilder and the ProviderScope. Now we can simply test the Widget as it were a normal Widget. With the findWidgets, we can find the CategoryWidgets and validate the state. Afterwards, 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. In the ProviderScope we can choose which provider to override and choose which value we want 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 you application calls an endpoint to load the initial categories. Luckily we can override the provider. In this case, we must remember to also override the value for the state. Of course we can also mock the state in more complex situations. In our case, we can simply 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();
    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. The same method can be used to return values from the methods or to throw exceptions when methods of the StateNotifier are called.

  testWidgets( 'Verify toggle is called', (tester) async {
    final mockStateNotifier = MockStateNotifier();
    final state = Map();
    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