Writing tests for applications is not the most fun task. We get so excited about seeing our code running in production that we don’t think it might fail.Â
This is about to change.
Flutter tests
Have you heard of Flutter? No? Let’s fix that. Flutter is the thing for mobile development these days. Powered by Dart, Flutter makes it incredibly easy to develop a single code base for the different client environments such as iPhone, Apple Watch, Apple TV, Android, and more. This Google framework is even tackling other platforms like Web and Desktop.
But we have had different hybrid technologies for years now, so why should we care for a new one?Â
NEW RESEARCH: LEARN HOW DECISION-MAKERS ARE PRIORITIZING DIGITAL INITIATIVES IN 2024.
We recently published a video on our Youtube channel, exploring five reasons why Flutter is a big deal. But, for now, let’s stick with one strong reason — Flutter makes writing tests a fun task due to its incredible developer ecosystem.Â
You can easily start writing Flutter widget tests by importing the package `package: flutter_test/flutter_test.dart`. It will unveil methods to spin up your widget, declare your tests statements, and allow you to find elements and expect the behaviors. Once it’s ready, a simple `flutter test` will start running the test code.Â
Let’s dive into an example to understand better.
The application
Let’s take the example of a to-do list app that sends reminders to users and encourages them to get work done. You might have used a similar app. Perhaps, this article is on your to-do list right now.
The app requirements are simple at this point. Clicking on the add button will allow us to add a new task to the list. Clicking on the task will remove it. We are also able to reorder list items by dragging-dropping them. There are three behaviors we need to write test code to check:Â
- The app should render a collection of tasks
- The app should allow reordering tasks by drag-drop
- The app should remove a task once the item is tapped
The code
Our Flutter project is pretty straightforward. For simplicity, we’re going to cover only the widgets that matter for our testing experiment. This experiment was built using material design.
Our app begins with a list view that is a stateless widget. It receives a collection of strings, each one representing a pending task. The widget’s responsibility is to map each task on a ListTile and render it on a ReorderableListView. We have added some styling to make the list view pretty.
class TaskList extends StatelessWidget { const TaskList( {Key? key, required this.tasks, required this.onRemove, required this.onReorder}) : super(key: key); final List tasks; final Function onRemove; final Function onReorder; @override Widget build(BuildContext context) { final ColorScheme colorScheme = Theme.of(context).colorScheme; final Color oddItemColor = colorScheme.primary.withOpacity(0.05); final Color evenItemColor = colorScheme.primary.withOpacity(0.15); final tiles = tasks .map((task) => ListTile( leading: const FlutterLogo(), key: Key(task), tileColor: int.parse(task.substring(5)).isOdd ? oddItemColor : evenItemColor, title: Text(task), onTap: () { onRemove(task); })) .toList(growable: true); return ReorderableListView( onReorder: (int oldIndex, int newIndex) { if (oldIndex < newIndex) { newIndex -= 1; } final String item = tasks.removeAt(oldIndex); tasks.insert(newIndex, item); onReorder(); }, children: tiles); } }
Flutter documentation focuses on reactive style widgets rather than imperative ones. The minimalist approach leads us to have a stateless component since we just need to render a constructor argument – `tasks`.Â
Also, the list exposes two behaviors that are treated as events: `onRemove` and `onReorder`. Their names speak for themselves. The reorder capability allows the user to drag-drop items on the screen.
The test
The requirements we need were detailed before. The first behavior is to test if the widget will render the collection of tasks – strings in this case – on the screen. From the `flutter_test` module we can start using the functions `group` and `testWidgets` inside a `main` method, define our tests. It feels like home if you’re coming from the JS world.
Each `testWidgets` represents a scenario we want to test. It receives a string representing what we are trying to test, and as a second argument: a function that provides a `WidgetTester` instance.
The `WidgetTester` instance allows us to pump our widget, which will basically emulate a test environment and run our widget alone.`pumpWidgets` is an asynchronous method, so we’re expected to `await` it. In the end, we need to expect the widget to have our collection of strings there. For that, we can simply find the text we’re looking for and expect one of each to exist.
void main() { group('TaskList', () { testWidgets('Should render the collection of tasks', (WidgetTester tester) async { const tasks = ['Task 1', 'Task 2', 'Task 3']; await tester.pumpWidget(MaterialApp( home: Material( child: Directionality( textDirection: TextDirection.ltr, child: Center( child: TaskList(tasks: tasks, onRemove: () {}, onReorder: () {}), ), ), ), )); expect(find.text('Task 1'), findsOneWidget); expect(find.text('Task 2'), findsOneWidget); expect(find.text('Task 3'), findsOneWidget); });
The second requirement is the `onRemove` event. By tapping the task, the user expects it to be removed from the view where the pending tasks live. Since `onRemove` is a function, a Dart method, we need to mock it and expect it to be called with certain parameters after we tap the item. For that, mockito is our best friend.
class OnRemoveMockFunction extends Mock implements Function { void call(String task); } class OnReorderMockFunction extends Mock implements Function { void call(); }
By leveraging the `Mock` and `Function` objects, we can declare an `OnRemoveMockFunction` representing the real function in our code. We need to declare a body-less void method `call` with the arguments the real function would expect, so the language can replace and call the mocked function on demand. Since it extends from `Mock`, we’re provided with a few capabilities; the main one is to check how many times it was executed and with what arguments.
The next action is to provide an instance of our mock function to the expected argument on the `TaskList` constructor. That will indicate that our mock function will be the one called once the user taps the item. To tap the item, we can use the `find` function to get the task we want by its text. `WidgetTester` allows us to tap the widget. In the end, our expectation is as easy as verifying if the mock function was called with the given argument.
testWidgets('Should call the onRemove function', (WidgetTester tester) async { const tasks = ['Task 1', 'Task 2', 'Task 3']; final onRemove = OnRemoveMockFunction(); await tester.pumpWidget(MaterialApp( home: Material( child: Directionality( textDirection: TextDirection.ltr, child: Center( child: TaskList(tasks: tasks, onRemove: onRemove, onReorder: () {}), ), ), ), )); verifyNever(onRemove('Task 1')); final taskItem = find.text('Task 1'); expect(taskItem, findsOneWidget); await tester.tap(taskItem); await tester.pump(); verify(onRemove('Task 1')).called(1); });
Last but not the least, we need to verify our drag-drop reorder behavior. Basically, we need to think about our collection of tasks, the one we passed as an argument to the constructor. Since we’re leveraging the `ReorderableListView` widget from the Material library, the whole drag-and-drop thing is done by default. So our job in the production code is to reorder the collection of tasks every time an item is dropped.Â
The testing for that is a sequence of some events. First, we need to mock our onReorder function to verify this event is triggered during the reorder action.Â
Second, we need to spin up our component with a collection of tasks with a given state and order. Once the tester pumps up our widget, we need to simulate the drag-drop behavior. For that, we can use the `startGesture` method from`WidgetTester`.Â
The `TestGesture` instance returned by the `startGesture` method will allow us to finish the gesture. The drag-drop gesture is defined by a place (in the order) to start and to end. Using directions of the position of a given item on the list, we can `moveTo` our gesture to after the item we want by finding it. The tester needs to be pumped twice during this process since the screen needs to render once the user starts dragging when the user drops the item, and right after it when the item is in its new place.Â
Notice the constants `kLongPressTimeout` and `kPressTimeout` use `k` as a prefix as suggested by the Dart Style guide.
In the end, we will ensure that the sequence the list is showing on the screen is not the one we started the widget with. Instead, it’s the sequence after the drag-drop gesture. We also expect the onReorder to be called once without arguments.Â
testWidgets('Should reorder the collection of tasks', (WidgetTester tester) async { final onReorder = OnReorderMockFunction(); final tasks = ['Task 1', 'Task 2', 'Task 3']; final tasksWidget = TaskList(tasks: tasks, onRemove: () {}, onReorder: onReorder); await tester.pumpWidget(MaterialApp( home: Material( child: Directionality( textDirection: TextDirection.ltr, child: Center( child: tasksWidget, ), ), ), )); expect(tasksWidget.tasks, orderedEquals(['Task 1', 'Task 2', 'Task 3'])); final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Task 1'))); await tester.pump(kLongPressTimeout + kPressTimeout); await drag.moveTo(tester.getTopLeft(find.text('Task 3'))); await drag.up(); await tester.pumpAndSettle(); expect(tasksWidget.tasks, orderedEquals(['Task 2', 'Task 3', 'Task 1'])); verify(onReorder()).called(1); });
Final thoughts
We all know that automated testing is important. It’s our job to ensure that our applications behave how they are supposed to.Â
Sometimes, writing tests doesn’t seem as cool as writing the production code due to various problems around technologies, frameworks, and languages.
The Flutter community is focused on making our life easier and more fun. So it provides on its SDK different strategies for testing our apps. For a minimalist stateless component, testing is not complicated at all. Even with a drag-drop capability, our test was pretty straightforward.Â
There are more complicated scenarios out there, which we’ll be covering in our upcoming Flutter posts. Until then, have fun exploring Flutter.Â