Following our lastย blog post on Flutter, this article will teach you how to write tests for stateful widgets.ย
Dealing with state when writing tests is sometimes a complex task. We need to emulate different state scenarios to have the widget behave as expected. When writing tests for stateful widgets, we need to understand how to reproduce the possible scenarios users will encounter when using the app in production.
NEW RESEARCH: LEARN HOW DECISION-MAKERS ARE PRIORITIZING DIGITAL INITIATIVES IN 2024.
In the last experiment, we used Provider and Change notifier together with the ViewModel pattern and achieved a light strategy for state management. Today, we will learn how to leverage Mockito to mock our state and assert our widget’s behavior.ย
The Experiment
Following up onย the experimentย presented in our previousย article, we have a simple to-do list to add pending tasks. This article will focus on a different part of the experiment โย How the page that creates/updates tasks works.ย
The requirements are pretty simple:ย
- The user should be able to create a task.
- The user should be able to update an existing task.
The Test
You learned how to leverage Provider, ChangeNotifier, and the ViewModel pattern to a light state management strategy in the previous article. So, the next question is: How to unit test it?
The first action is to set up a mocking strategy. We want to ensure our widget will be tested without any dependency, including the view model. We also need to mock the view model class to provide different states for the widget and assert its behavior.
Mockito handles everything we need. It allows us to create a mock state and prepare the test by defining what we want to return when any state methods are called.ย
Let’s start by creating the mock. There are two roads available here. The first option is to manually create a mock class as we did for the task list events in this blog. The problem with that is that many methods inside the model would need to be mocked, which the task list events didn’t have. So let’s take the second road โ we can use a mock generator and do it by running a command line. All you need to do is to install theย build_runnerย package using pub install and annotate the test to have it generated.
@GenerateMocks([TaskDetailsViewModel]) main() { group('TaskDetailsView', () {
Once you have your code set up, you just need to run the code generation:
flutter packages pub run build_runner build
Now that we have generated our mock let’s use it to prepare our test by adding conditions to return the values we want once a given state behavior is called.
Our widget relies on the model class to provide data. If we open the widget with a given task in an update action, our model will provide the widget’s title, description, and due date. If it is a create action, our model will provide default values for the fields, null/empty strings, and the current date/time.ย
Let’s start with the create scenario:
@GenerateMocks([TaskDetailsViewModel]) main() { group('TaskDetailsView', () { final model = MockTaskDetailsViewModel(); testWidgets('Should hit the save button and call the model', (WidgetTester tester) async {
Mockito provides us with a function that registers results for each method called inside the mock class. Let’s register the results for the getters the widget calls to get the task fields data.
final task = Task.nullObject(); when(model.exists).thenReturn(task.exists); when(model.title).thenReturn(task.title); when(model.description).thenReturn(task.description); when(model.dueDateFormatted) .thenReturn(DateFormat.yMEd().format(task.dueDate)); when(model.save()).thenAnswer((_) => Future.value(task));
Notice that we are using theย Null Object patternย here to create an instance of a task. This helps us create a valid task object without running into null problems. That factory will return a task object with the default definitions for each property.
There is an additional method to register there. Once the user submits the form, we call the model’s save action that calls the backend in the real code to store the task. We mock that to return the task already created, just to mock all the actions we call on the model. In the end, we will be able to verify its calls and then the updating scenario.
The key difference between the create and updating scenario is that for the updating scenario, we are building a task with real data, not using the Null Object pattern. We will provide the widget a real title, description, and due dates so the widget will behave as it should for a real user scenario.
final oldTask = Task(title: 'Task 1', dueDate: DateTime.now(), description: 'Task 1'); final newTask = Task(title: 'Task 1', dueDate: DateTime.now(), description: 'Task 1'); when(model.title).thenReturn(oldTask.title); when(model.description).thenReturn(oldTask.description); when(model.dueDateFormatted) .thenReturn(DateFormat.yMEd().format(oldTask.dueDate)); when(model.save()).thenAnswer((_) => Future.value(newTask));
After preparing the mock state for our widgets, the next move is to pump the widget. Since what differentiates create/update is the state, we can create both using the same code snippet:
await tester.pumpWidget(MaterialApp( home: Material( child: Directionality( textDirection: TextDirection.ltr, child: Center( child: MultiProvider( providers: [ ChangeNotifierProvider( create: (context) => model), ], child: const TaskDetailsView(), ), ), ), ), ));
Note that we are using the Provider package again to register our model that will be consumed by the widget. Provider/Consumer relies on the generic typing under the diamond operator. So, in that case, we will pass our real production model, which is extended by the mock class.
Now, the widget is up and running with the mocked state we created. Our next move is to emulate a user behavior of typing data inside the form. We will get every field the widget has by its key and use the widgetTester instance to enter text.ย
Here is the create scenario:
final titleInputText = find.byKey(const Key("title-text-form-field")); expect(titleInputText, findsOneWidget); final title = (tester.element(titleInputText).widget as TextFormField).initialValue; expect(title, isEmpty); await tester.enterText(titleInputText, task.title); if (task.description != null) { final descriptionInputText = find.byKey(const Key("description-text-form-field")); expect(descriptionInputText, findsOneWidget); final description = (tester.element(descriptionInputText).widget as TextFormField).initialValue; expect(description, isEmpty); await tester.enterText(descriptionInputText, task.description!); }
Here is the update scenario:
final titleInputText = find.byKey(const Key("title-text-form-field")); expect(titleInputText, findsOneWidget); final title = (tester.element(titleInputText).widget as TextFormField).initialValue; expect(title, oldTask.title); await tester.enterText(titleInputText, newTask.title); if (oldTask.description != null) { final descriptionInputText = find.byKey(const Key("description-text-form-field")); expect(descriptionInputText, findsOneWidget); final description = (tester.element(descriptionInputText).widget as TextFormField).initialValue; expect(description, oldTask.description); await tester.enterText(descriptionInputText, oldTask.description!); }
Notice that we are checking, in the update scenario, to assert if the field’s initial value is exactly the same provided in the state.
There is one more complexity to be addressed for the due date input. Instead of typing the date, we select it inside the date picker.
final dueDateInputText = find.byKey(const Key("due-date-text-form-field")); expect(dueDateInputText, findsOneWidget); final dueDateNullable = (tester.element(dueDateInputText).widget as TextFormField).initialValue; if (dueDateNullable != null) { final dueDate = DateFormat.yMEd().parse(dueDateNullable); expect(dueDate, equals(DateUtils.dateOnly(oldTask.dueDate))); } final dueDateDatePickerButton = find.byKey(const Key("due-date-date-picker-button")); expect(dueDateDatePickerButton, findsOneWidget); await tester.tap(dueDateDatePickerButton); await tester.pumpAndSettle(); await tester.tap(find.text('OK'));
Our test’s third and last step is to execute the submit button and verify if the model’s save method was called. For that, we will use a widget tester to tap the submit button and Mockito’s verify method. The snippet is the same for both create and update tests.
final saveButton = find.byKey(const Key('submit-button')); expect(saveButton, findsOneWidget); await tester.tap(saveButton); verify(model.save()).called(1);
ย A simple flutter test command will execute our all-green tests.
Final Thoughts
When writing tests for a widget, the state is crucial for reproducing the behavior we want to verify. There are different strategies to achieve this, but mocks are usually the way to go for unit tests. Mockito allows us to handle mocking productively, and its code generation helps us avoid hours coding stubs with mock behavior.ย
Provider, Change Notifier, with some Dart generic, polymorphism approaches and the power of Mockito, have made it easier to prepare data context, pump our widgets with the needed dependencies, and assert its behavior.
The code used in the example is availableย here.
Wesley Fuchter
Related Posts
-
Introduction to Flutter Widget Testing
Writing tests for applications is not the most fun task. We get so excited about…
-
How to Build a Stateful Widget in Flutter?
In our last Flutter blog, we explained writing tests for stateless widgets. Stateless code is…