In this tutorial about Widget Testing with Flutter, you’ll learn how to ensure UI widgets look and behave as expected by writing test code.
Update note: Stephanie Patterson updated this tutorial for Flutter 3.3 and Dart 2.18. Lawrence Tan wrote the original.
Testing is important during your app development. As your product grows, it gets more complex, and performing manual tests becomes more difficult. Having an automated testing environment helps optimize this process.
Widget testing is like UI testing: You develop the look and feel of your app, ensuring every interaction the user makes produces the expected result.
For this tutorial, you’ll write widget tests for a car app called Drive Me, which lets users view a list of cars, view details about them and select and view specific cars. In the process, you’ll learn how to test that the app properly performs the following functions:
- Loads mock data to the widget tests.
- Injects erroneous mock data for negative tests.
- Ensures that the app presents a list of sorted cars and displays its details.
- Checks that the car selection appears correctly in the list page.
- Ensures that the car details page displays correctly.
Getting Started
To start, download the starter project by clicking the Download Materials button at the top or bottom of the tutorial, then explore the starter project in Visual Studio Code. You can also use Android Studio, but this tutorial uses Visual Studio Code in its examples.
Make sure to run flutter packages get either at the command line or when prompted by your IDE. This pulls the latest version of the packages needed for this project.
Note: In the starter project, you will likely see warnings about Unused import or variables not being used. Ignore these as they will be used by the time you have completed this tutorial.
Build and run the project with flutter run to familiarize yourself with how the app works.
Exploring the Starter Project
The starter project includes the implementation of the app so you can focus on widget testing. Take a look at the contents in lib to understand how the app works.
Starting at the bottom, as you know main.dart is the file where all Flutter apps start. dependency_injector.dart is where the app registers the main data layer classes and injects them via get_it.
constants.dart contains most of the variables you’ll use throughout the app.
The project has four main folders:
- database
- details
- list
- models
In the lib/models folder, you’ll find an important file. car.dart is where the Car() and CarsList() model implementations reside. The CarsList() model holds a list of cars and an error message if an exception occurs.
Next, look at lib/list/cars_list_bloc.dart. This is the CarsList() data layer. CarsListBloc loads data from the JSON found in assets/sample_data/data.json and passes it to the widget list. Thereafter, it sorts the cars alphabetically via alphabetizeItemsByTitleIgnoreCases().
In the lib/details folder is car_details_bloc.dart, which gets data from CarsListBloc and passes it to the CarDetails widget in car_details_page.dart.
Open lib/details/car_details_page.dart. You’ll see that on init it retrieves the data passed in by CarDetailsBloc and presents it on the widget. When users select or deselect items, CarsListBloc() makes the updates.
When the user selects any car, a separate data stream manages it.
lib/database contains cars_database.dart, which implements an abstract class called CarsDataProvider. This class contains loadCars() that parses the JSON file containing a list of car data. The parsed data returned is a CarsList().
As you guessed it from some filenames, this project uses BLoC to pass data between the widgets layer and the data layer.
Now that you’ve tried the app and understand the implementation details, it’s time to start running some tests.
Before you dive deep into the topic of widget testing with Flutter, take a step back and compare it with unit testing.
Unit Testing vs. Widget Testing
Unit testing is a process where you check for quality, performance or reliability by writing extra code that ensures your app logic works as expected. It tests for logic written in functions and methods. The unit tests then grow and accumulate to cover an entire class and subsequently a huge part of the project, if not all.
The goal of a widget test is to verify that every widget’s UI looks and behaves as expected. Fundamentally, you perform tests by re-rendering the widgets in code with mock data.
This also tells you that if you modify the logic of the app — for example, you change the login validation of the username from a minimum of six characters to seven — then your unit test and widget test may both fail together.
Tests lock down your app’s features, which help you to properly plan your app’s design before developing it.
Testing Pyramid
There are three types of tests you can perform with Flutter:
- Unit tests: Used to test a method or class.
- Widget tests: These test a single widget.
- Integration tests: Use these to test the critical flows of the entire app.
So, how many tests will you need? To decide, take a look at the testing pyramid. It summarizes the essential types of tests a Flutter app should have:
Essentially, unit tests should cover most of the app, then widget tests and, lastly, integration tests.
Even when good testing grounds are in place, you shouldn’t omit manual testing.
As you go up the pyramid, the tests get less isolated and more integrated. Writing good unit tests help you build a strong base for your app.
Now that you understand the need for testing, it’s time to dive into the project for this tutorial!
Widget Testing the Car List
Open test/list/cars_list_bloc_test.dart. Look below // TODO 3: Unit Testing Data Loading Logic and you’ll see the unit tests implemented in this project. These unit tests ensure that the data structure you provide to the widget is accurate.
Before going into writing the test scripts, it’s good to look at the actual screen you’re testing. In test/database/mock_car_data_provider.dart, the user has selected the first car — the Hyundai Sonata 2017, shown the image below:
Are you ready to start adding widget tests?
Your First Test
Open test/list/cars_list_page_test.dart and add the following beneath // TODO 4: Inject and Load Mock Car Data:
carsListBloc.injectDataProviderForTest(MockCarDataProvider());
This is injecting the mock car test data into carsListBloc.
Beneath // TODO 5: Load & Sort Mock Data for Verification add:
final cars = await MockCarDataProvider().loadCars();
cars.items.sort(carsListBloc.alphabetizeItemsByTitleIgnoreCases);
Here you’re waiting for the mock car data to load and then sort the list.
Injecting test data
Now it’s time to inject the test data.
Add these lines of code below // TODO 6: Load and render Widget:
await tester.pumpWidget(const ListPageWrapper());
await tester.pump(Duration.zero);
pumpWidget() renders and performs a runApp of a stateless ListPage widget wrapped in ListPageWrapper(). Then, you call pump() to render the frame and specify how long to wait. In this case you don’t want a delay so Duration.zero is used.
This prepares the widget for testing!
Note: pumpWidget calls runApp, and also triggers a frame to paint the app. This is sufficient if your UI and data are all provided immediately from the app, or you could call them static data (i.e., labels and texts).
When you have a structure (i.e. list, collections) with repeated data models, pump() becomes essential to trigger a rebuild since the data-loading process will happen post-runApp.
Ensuring visibility
Beneath // TODO 7: Check Cars List’s component’s existence via key to ensure that the Carslist is in the view add these lines of code:
final carListKey = find.byKey(const Key(carsListKey));
expect(carListKey, findsOneWidget);
If you look at lib/list/cars_list_page.dart, you will see that the widget tree identifies ListView() with a key called carsListKey(). findsOneWidget uses a matcher to locate exactly one such widget.
The mock data in mock_car_data_provider.dart has a total of six cars, but you don’t want to write a test for each one. A good practice is to use a for loop to iterate through and verify each car on the list.
Return to test/list/cars_list_page_test.dart and below // TODO 8: Create a function to verify list’s existence add this:
void _verifyAllCarDetails(
List<Car> carsList,
WidgetTester tester,
) async {
for (final car in carsList) {
final carTitleFinder = find.text(car.title);
final carPricePerDayFinder = find.text(
pricePerDayText.replaceFirst(
wildString,
car.pricePerDay.toStringAsFixed(2),
),
);
await tester.ensureVisible(carTitleFinder);
expect(carTitleFinder, findsOneWidget);
await tester.ensureVisible(carPricePerDayFinder);
expect(carPricePerDayFinder, findsOneWidget);
}
}
This test verifies that the title and the price per day display correctly. This is possible because of a function called ensureVisible().
To see more about ensureVisible(), hover over it to see its description automatically displayed.
Note: You wrap a ListView in a SingleChildScrollView to make this work in cars_list_page.dart. At the time of writing, you must do this for the test to pass.
Theoretically, a ListView also contains a scrollable element to allow scrolling. The test doesn’t currently verify images.
Testing images is expensive: It requires getting data from the network and verifying chunks of data. This can lead to a longer test duration as the number of test cases increases.
To verify the car details, find // TODO 9: Call Verify Car Details function and add this below it to call to the function you just created:
_verifyAllCarDetails(cars.items, tester);
In the next section you’ll learn how to add tests to verify the selected car has a blue background.
Widget Testing the Car List Page with Selection
Remember when you select a car it has a blue background? You need to create a test to ensure that happens.
Still in cars_list_page_test.dart, add this beneath // TODO 10: Select a Car:
carsListBloc.selectItem(1);
The widget tester attempts to select Car ID 1.
Find // TODO 11: Verify that Car is highlighted in blue add the following below it:
// 1
bool widgetSelectedPredicate(Widget widget) =>
widget is Card && widget.color == Colors.blue.shade200;
// 2
bool widgetUnselectedPredicate(Widget widget) =>
widget is Card && widget.color == Colors.white;
expect(
find.byWidgetPredicate(widgetSelectedPredicate),
findsOneWidget,
);
expect(
find.byWidgetPredicate(widgetUnselectedPredicate),
findsNWidgets(5),
);
Here you have created two predicates:
- Verify the selected card has a blue background
- Ensure the unselected card remains white
Run this test now. Hurray, your new test passes! :]
You’re doing very well. It’s time to try some negative tests before finishing with the testing of the car details page.
Negative Tests for Car List Page
Now it’s time to test for errors. To simulate errors, add the following below // TODO 12: Inject and Load Error Mock Car Data:
carsListBloc.injectDataProviderForTest(MockCarDataProviderError());
You’ve injected data before. The only difference here is that you inject MockCarDataProviderError(), which contains mock error data.
Below // TODO 13: Load and render Widget add:
await tester.pumpWidget(const ListPageWrapper());
await tester.pump(Duration.zero);
As before, pumpWidget() and pump() trigger a frame to paint and render immediately.
Beneath // TODO 14: Verify that Error Message is shown add the following to add error messages.
final errorFinder = find.text(
errorMessage.replaceFirst(
errorMessage,
mockErrorMessage,
),
);
expect(errorFinder, findsOneWidget);
This replaces the errorMessage with the mockErrorMessage and confirms the error message displays.
Ready for your fifth test? Run it.
Great job! Your fifth test passed!
Verifying view update
There’s one last test you need to perform for this widget, which is to verify the widget updates its view if data comes in after getting an error.
You need to test if your app doesn’t have any cars to display.
Since this next step includes code you’ve already used, you’re going to do a large update at once. Find and replace // TODO Replace testWidgets(”’After encountering an error…”’ and the entire placeholder testWidgets() beneath it with:
testWidgets(
”’After encountering an error, and stream is updated, Widget is also
updated.”’,
(WidgetTester tester) async {
// TODO 15: Inject and Load Error Mock Car Data
carsListBloc.injectDataProviderForTest(MockCarDataProviderError());
// TODO 16: Load and render Widget
await tester.pumpWidget(const ListPageWrapper());
await tester.pump(Duration.zero);
// TODO 17: Verify that Error Message and Retry Button is shown
final errorFinder = find.text(
errorMessage.replaceFirst(
errorMessage,
mockErrorMessage,
),
);
final retryButtonFinder = find.text(retryButton);
expect(errorFinder, findsOneWidget);
expect(retryButtonFinder, findsOneWidget);
// TODO 18: Inject and Load Mock Car Data
carsListBloc.injectDataProviderForTest(MockCarDataProvider());
await tester.tap(retryButtonFinder);
// TODO 19: Reload Widget
await tester.pump(Duration.zero);
// TODO 20: Load and Verify Car Data
final cars = await MockCarDataProvider().loadCars();
_verifyAllCarDetails(cars.items, tester);
},
);
Here’s what the code does:
- TODO 15–17: These are the same as the tests you did in the last step.
- TODO 18: Injects car mock data.
- TODO 19: Reloads the widget.
- TODO 20: Waits for mock dat to load and then verifies the car details.
Time to run the test. Run it now, and …
Awesome work! Your sixth test passes!
You’ve tested for when a car is selected. What about when it’s been deselected? You guessed it, that’s next.
Widget Testing the Car Details Page for the Deselected Car
Take another look at the Car Details Page. Here is an example of a selected car and another that has not been selected.
Notice how the title and button text are different depending on the user’s choice. You need to test for that.
Open test/details/car_details_page_test.dart add replace // TODO Replace testWidgets(‘Unselected Car Details Page…’ along with the corresponding placeholder testWidgets() code with this:
testWidgets(
‘Unselected Car Details Page should be shown as Unselected’,
(WidgetTester tester) async {
// TODO 21: Inject and Load Mock Car Data
carsListBloc.injectDataProviderForTest(MockCarDataProvider());
await carsListBloc.loadItems();
// TODO 22: Load & Sort Mock Data for Verification
final cars = await MockCarDataProvider().loadCars();
cars.items.sort(carsListBloc.alphabetizeItemsByTitleIgnoreCases);
// TODO 23: Load and render Widget
await tester.pumpWidget(
const DetailsPageSelectedWrapper(2)); // Mercedes-Benz 2017
await tester.pump(Duration.zero);
// TODO 24: Verify Car Details
final carDetailKey = find.byKey(const Key(carDetailsKey));
expect(carDetailKey, findsOneWidget);
final pageTitleFinder =
find.text(cars.items[1].title); // 2nd car in sorted list
expect(pageTitleFinder, findsOneWidget);
final notSelectedTextFinder = find.text(notSelectedTitle);
expect(notSelectedTextFinder, findsOneWidget);
final descriptionTextFinder = find.text(cars.items[1].description);
expect(descriptionTextFinder, findsOneWidget);
final featuresTitleTextFinder = find.text(featuresTitle);
expect(featuresTitleTextFinder, findsOneWidget);
final allFeatures = StringBuffer();
for (final feature in cars.items[1].features) {
allFeatures.write(‘n $feature n’);
}
final featureTextFinder = find.text(allFeatures.toString());
await tester.ensureVisible(featureTextFinder);
expect(featureTextFinder, findsOneWidget);
final selectButtonFinder = find.text(selectButton);
await tester.scrollUntilVisible(selectButtonFinder, 500.0);
expect(selectButtonFinder, findsOneWidget);
},
);
Here’s what you accomplished with the code above:
- TODO 21–23: Once again, you inject, load and sort the data, then prepare and pump the widget.
- TODO 24: If you open lib/details/car_details_page.dart, you’ll find a widget that’s identified with a key, a page title, a deselected title, a features list and a selectButton. The code in this TODO helps you to verify these widgets! scrollUntilVisible() scrolls through the scrollable widget, in your app’s case the ListView widget, until the expected widget is found.
Time to run your tests.
You have created a variety of tests. Great job! Are you ready for a challenge?
Widget Testing Challenge
Your challenge is to use what you’ve learn and complete the final tests on your own. You can do it!
If you get stuck or want to compare solutions, just click the Reveal button. Give it a try first. :]
Objectives:
- The selected Car Details Page should show a static Selected text at the top of the page. When viewing a selected car, the details page should be represented correctly.
- When selecting and deselecting a car, the details page should update accordingly.
The solution is broken up into two tests. You’ll still be working in car_details_page_test.dart. Hint, TODO 25–28 and TODO 29–32 are listed in the project.
[spoiler]
Testing Details Page for Selected Cars
TODO 25–28:
testWidgets(
‘Selected Car Details Page should be shown as Selected’,
(WidgetTester tester) async {
// TODO 25: Inject and Load Mock Car Data
carsListBloc.injectDataProviderForTest(MockCarDataProvider());
await carsListBloc.loadItems();
// TODO 26: Load and render Widget
await tester.pumpWidget(
const DetailsPageSelectedWrapper(3)); // Hyundai Sonata 2017
await tester.pump(Duration.zero);
// TODO 27: Load Mock Data for Verification
final actualCarsList = await MockCarDataProvider().loadCars();
final actualCars = actualCarsList.items;
// TODO 28: First Car is Selected, so Verify that
final carDetailKey = find.byKey(const Key(carDetailsKey));
expect(carDetailKey, findsOneWidget);
final pageTitleFinder = find.text(actualCars[2].title);
expect(pageTitleFinder, findsOneWidget);
final notSelectedTextFinder = find.text(selectedTitle);
expect(notSelectedTextFinder, findsOneWidget);
final descriptionTextFinder = find.text(actualCars[2].description);
expect(descriptionTextFinder, findsOneWidget);
final featuresTitleTextFinder = find.text(featuresTitle);
expect(featuresTitleTextFinder, findsOneWidget);
final actualFeaturesStringBuffer = StringBuffer();
for (final feature in actualCars[2].features) {
actualFeaturesStringBuffer.write(‘n $feature n’);
}
final featuresTextFinder =
find.text(actualFeaturesStringBuffer.toString());
await tester.ensureVisible(featuresTextFinder);
expect(featuresTextFinder, findsOneWidget);
final selectButtonFinder = find.text(removeButton);
//await tester.ensureVisible(selectButtonFinder);
await tester.scrollUntilVisible(selectButtonFinder, 500.0);
expect(selectButtonFinder, findsOneWidget);
},
);
Test that the Selected Car Updates the Widget
TODO 29–32:
testWidgets(
‘Selecting Car Updates the Widget’,
(WidgetTester tester) async {
// TODO 29: Inject and Load Mock Car Data
carsListBloc.injectDataProviderForTest(MockCarDataProvider());
await carsListBloc.loadItems();
// TODO 30: Load & Sort Mock Data for Verification
final cars = await MockCarDataProvider().loadCars();
cars.items.sort(carsListBloc.alphabetizeItemsByTitleIgnoreCases);
// TODO 31: Load and render Widget for the first car
await tester.pumpWidget(
const DetailsPageSelectedWrapper(2)); // Mercedes-Benz 2017
await tester.pump(Duration.zero);
// TODO 32: Tap on Select and Deselect to ensure widget updates
final selectButtonFinder = find.text(selectButton);
await tester.scrollUntilVisible(selectButtonFinder, 500.0);
await tester.tap(selectButtonFinder);
await tester.pump(Duration.zero);
final deselectButtonFinder = find.text(removeButton);
//await tester.ensureVisible(deselectButtonFinder);
await tester.scrollUntilVisible(deselectButtonFinder, 500.0);
await tester.tap(deselectButtonFinder);
await tester.pump(Duration.zero);
final newSelectButtonFinder = find.text(selectButton);
//await tester.ensureVisible(newSelectButtonFinder);
await tester.scrollUntilVisible(newSelectButtonFinder, 500.0);
expect(newSelectButtonFinder, findsOneWidget);
},
);
[/spoiler]
After you’ve finished your challenge, rerun your tests. There are nine tests and they’ve all passed! :]
Congratulations! You’re now an official Widget Testing Ambassador, go forth and spread the good news!
Where to Go From Here?
Download the final project by clicking the Download Materials button at the top or bottom of this tutorial.
For your next steps, expand your Flutter testing knowledge by exploring the official UI tests cookbook from the Flutter team.
Then take your testing to the next level by exploring and integrating Mockito to mock live web services and databases.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
Source by www.kodeco.com