Home · Flutter Tutorials

Widget Testing With Flutter: Getting Started

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.

5/5 2 Ratings

Version

  • Other, Flutter 1.12, VS Code

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.
Note: Note: This tutorial assumes you have some basic knowledge of Flutter. If you are new to this family, take a look at our Getting Started With Flutter tutorial before proceeding.

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, rxdart and get_it, which this project uses.

Build and run the project with flutter run to familiarize yourself with how the app works.

Car List

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.

Project Structure

The project has four main folders:

  • database
  • details
  • list
  • models

database contains one main file called cars_database.dart, which implements an abstract class called CarsDataProvider that implements loadCars(). This method parses the JSON file, which has a list of car data, and returns that data as a CarsList. The model then holds a list of cars and an error message if an exception occurs.

This project uses BLoC to pass data between the widgets layer and the data layer.

Note: To learn more about BLoC, visit Getting Started With BLoC Pattern.

Within the details folder, you’ll find car_details_bloc.dart, which gets data from CarsListBloc. It passes the data to the CarDetails widget in car_details_page.dart.

Next, open car_details_page.dart. On init, it retrieves the data via CarDetailsBloc and presents it on the widget. When users select or deselect items, CarsListBloc makes the updates.

Next, look at cars_list_bloc.dart in the list folder. This is the data layer of CarsList. This class loads data from JSON and passes it to the widget list. Thereafter, it sorts the cars alphabetically via alphabetiseItemsByTitleIgnoreCases().

When the user selects any car, a separate data stream manages it.

In the models folder, you’ll find one more important file. car.dart is where the implementations of Car and CarsList reside.

constants.dart contains most of the strings you’ll use throughout the app. dependency_injector.dart is where the app registers the main data layer classes and injects them via get_it.

Note: If you want to learn more about get_it and how dependency injection works, read Unit Testing With Flutter.

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 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 tests then grow and accumulate to cover an entire class and 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 helps you to properly plan the 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:

Testing Pyramid

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

Before you start widget testing, head to TODO 3 in test/list/cars_list_bloc_test.dart and take a look at 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 again. In test/database/mock_car_data_provider.dart, the user has selected the first car — the Hyundai Sonata 2017. After the list loads, the selected car will have a blue background. See the image below:

Car List with selected card highlighted in blue

Your First Test

Now, open cars_list_page_test and add these lines at their appropriate places marked by the TODO number:

testWidgets(
    "Cars are displayed with summary details, and selected car is highlighted blue.",
    (WidgetTester tester) async {
  // TODO 4: Inject and Load Mock Car Data
  carsListBloc.injectDataProviderForTest(MockCarDataProvider());

  // TODO 5: Load & Sort Mock Data for Verification
  CarsList cars = await MockCarDataProvider().loadCars();
  cars.items.sort(carsListBloc.alphabetiseItemsByTitleIgnoreCases);

  ...
});

Injecting test data

This will inject the test data.

Next, add these lines of code at TODO 6:

// TODO 6: Load and render Widget
await tester.pumpWidget(ListPageWrapper());
await tester.pump(Duration.zero);

Here, pumpWidget renders and performs a runApp of a stateless ListPage widget wrapped in ListPageWrapper. Then, you call pump to render the frame without delay. 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 I 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

First, ensure that the Carslist is in the view. Add these lines of code:

// TODO 7: Check Cars List's component's existence via key
final carListKey = find.byKey(Key(CARS_LIST_KEY));
expect(carListKey, findsOneWidget);

In cars_list_page.dart, you will see that the widget tree identifies ListView with a key called CARS_LIST_KEY. findsOneWidget uses a matcher to locate exactly one such widget.

Next, add this function at TODO 8:

// TODO 8: Create a function to verify list's existence
void _verifyAllCarDetails(List<Car> carsList, WidgetTester tester) async {
  for (var car in carsList) {
    final carTitleFinder = find.text(car.title);
    final carPricePerDayFinder = find.text(PRICE_PER_DAY_TEXT.replaceFirst(
        WILD_STRING, car.pricePerDay.toStringAsFixed(2)));
    await tester.ensureVisible(carTitleFinder);
    expect(carTitleFinder, findsOneWidget);
    await tester.ensureVisible(carPricePerDayFinder);
    expect(carPricePerDayFinder, findsOneWidget);
  }
}

The mock data displays 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.

Refer to the screenshot of the app at the beginning of this tutorial to get a clearer picture of what this test does. It verifies that the title and the price per day display correctly. This is possible because of a function called ensureVisible.

Hold Command and hover over ensureVisible to see its description. This function helps the test scroll through the widget tree until it finds the expected widget.
ensureVisible function documentation

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.

Call the function you just created to verify the car details:

// TODO 9: Call Verify Car Details function
_verifyAllCarDetails(cars.items, tester);

Try running the test now — yay, four tests passed!

4-tests-passed

Widget Testing the Car List Page with Selection

But hang on, your test isn’t done yet. Remember, when you select a car, it should get a blue background? Next, you’ll test to ensure that happens.

Add these lines of code:

// TODO 10: Select a Car
carsListBloc.selectItem(1);

// TODO 11: Verify that Car is highlighted in blue
WidgetPredicate widgetSelectedPredicate = (Widget widget) =>
    widget is Card && widget.color == Colors.blue.shade200;
WidgetPredicate widgetUnselectedPredicate =
    (Widget widget) => widget is Card && widget.color == Colors.white;

expect(find.byWidgetPredicate(widgetSelectedPredicate), findsOneWidget);
expect(find.byWidgetPredicate(widgetUnselectedPredicate), findsNWidgets(5));

Here’s what this code does:

  • TODO 10: The widget tester attempts to select Car ID 1.
  • TODO 11: It then creates two predicates: one to verify the selected card has a blue background and one to ensure the unselected card remains white.

Try running the test now. Hurray, your test still 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

From TODO 12 to 14, add these lines of code:

testWidgets('Proper error message is shown when an error occurred',
    (WidgetTester tester) async {
  // TODO 12: Inject and Load Error Mock Car Data
  carsListBloc.injectDataProviderForTest(MockCarDataProviderError());

  // TODO 13: Load and render Widget
  await tester.pumpWidget(ListPageWrapper());
  await tester.pump(Duration.zero);

  // TODO 14: Verify that Error Message is shown
  final errorFinder =
      find.text(ERROR_MESSAGE.replaceFirst(WILD_STRING, MOCK_ERROR_MESSAGE));
  expect(errorFinder, findsOneWidget);
});

Here’s what you’re doing with this code:

  • TODO 12–13: You’ve done this before. The only difference here is that you inject MockCarDataProviderError, which contains mock error data.
  • TODO 14 Verify that the error message displays.

Ready for your fifth test? Run it and yaay!!! The fifth test passed!

5-tests-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.

Check out how the app looks:

Carlist Error Data

Make the following changes for TODO 15–20:

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(ListPageWrapper());
  await tester.pump(Duration.zero);

  // TODO 17: Verify that Error Message is shown
  final errorFinder =
      find.text(ERROR_MESSAGE.replaceFirst(WILD_STRING, MOCK_ERROR_MESSAGE));
  final retryButtonFinder = find.text(RETRY_BUTTON);

  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
  CarsList cars = await MockCarDataProvider().loadCars();
  _verifyAllCarDetails(cars.items, tester);
});

Here’s what the code above does:

  • TODO 15–17: These are the same as the last test you did.
  • TODO 18: Injects proper mock data.
  • TODO 19: Reloads the widget.
  • TODO 20: Calls the same function to verify all car details.

Time to run the test. Run it now, and … awesome work! Your sixth test passes!

6-tests-passed

Widget Testing the Car Details Page for the Deselected Car

Finally, move on to the final widget: the Car Details Page. Look at the page again:

Car details screen

From TODO 21–24 in test/details/car_details_page_test.dart add these lines:

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
  CarsList cars = await MockCarDataProvider().loadCars();
  cars.items.sort(carsListBloc.alphabetiseItemsByTitleIgnoreCases);

  // TODO 23: Load and render Widget
  await tester.pumpWidget(DetailsPageSelectedWrapper(2)); // Mercedes-Benz 2017
  await tester.pump(Duration.zero);

  // TODO 24: Verify Car Details
  final carDetailsKey = find.byKey(Key(CAR_DETAILS_KEY));
  expect(carDetailsKey, findsOneWidget);

  final pageTitleFinder = find.text(cars.items[1].title); // 2nd car in sorted list
  expect(pageTitleFinder, findsOneWidget);

  final notSelectedTextFinder = find.text(NOT_SELECTED_TITLE);
  expect(notSelectedTextFinder, findsOneWidget);

  final descriptionTextFinder = find.text(cars.items[1].description);
  expect(descriptionTextFinder, findsOneWidget);

  final featuresTitleTextFinder = find.text(FEATURES_TITLE);
  expect(featuresTitleTextFinder, findsOneWidget);

  var allFeatures = StringBuffer();
  cars.items[1].features.forEach((feature) {
    allFeatures.write('\n' + feature + '\n');
  });

  final featureTextFinder = find.text(allFeatures.toString());
  await tester.ensureVisible(featureTextFinder);
  expect(featureTextFinder, findsOneWidget);

  final selectButtonFinder = find.text(SELECT_BUTTON);
  await tester.ensureVisible(selectButtonFinder);
  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 up the widget.
  • TODO 24: Open car_details_page.dart and you’ll find a widget that’s identified with a key, a page title, a deselected title, a features list and a Select button. This entire code helps you to verify them all!

Run the tests now. Your seventh test passed!

7-tests-passed

Widget Testing Challenge

Your challenge now is to complete the rest on your own. If you get stuck or want to compare solutions, just click Reveal.

Objectives:
  1. 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.
  2. When selecting and deselecting a car, the details page should update accordingly.

[spoiler]

Testing Details Page for Selected Cars

TODO 25–32:

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(DetailsPageSelectedWrapper(3)); // Hyundai Sonata 2017
  await tester.pump(Duration.zero);

  // TODO 27: Load Mock Data for Verification
  CarsList actualCarsList = await MockCarDataProvider().loadCars();
  List<Car> actualCars = actualCarsList.items;

  // TODO 28: First Car is Selected, so Verify that
  final carDetailsKey = find.byKey(Key(CAR_DETAILS_KEY));
  expect(carDetailsKey, findsOneWidget);

  final pageTitleFinder = find.text(actualCars[2].title);
  expect(pageTitleFinder, findsOneWidget);

  final notSelectedTextFinder = find.text(SELECTED_TITLE);
  expect(notSelectedTextFinder, findsOneWidget);

  final descriptionTextFinder = find.text(actualCars[2].description);
  expect(descriptionTextFinder, findsOneWidget);

  final featuresTitleTextFinder = find.text(FEATURES_TITLE);
  expect(featuresTitleTextFinder, findsOneWidget);

  var actualFeaturesStringBuffer = StringBuffer();
  actualCars[2].features.forEach((feature) {
    actualFeaturesStringBuffer.write('\n' + feature + '\n');
  });

  final featuresTextFinder = find.text(actualFeaturesStringBuffer.toString());
  await tester.ensureVisible(featuresTextFinder);
  expect(featuresTextFinder, findsOneWidget);

  final selectButtonFinder = find.text(REMOVE_BUTTON);
  await tester.ensureVisible(selectButtonFinder);
  expect(selectButtonFinder, findsOneWidget);
});

<h4>Test that the Selected Car Updates the Widget</h4>

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
  CarsList cars = await MockCarDataProvider().loadCars();
  cars.items.sort(carsListBloc.alphabetiseItemsByTitleIgnoreCases);

  // TODO 31: Load and render Widget for the first car
  await tester
      .pumpWidget(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(SELECT_BUTTON);
  await tester.ensureVisible(selectButtonFinder);
  await tester.tap(selectButtonFinder);

  await tester.pump(Duration.zero);

  final deselectButtonFinder = find.text(REMOVE_BUTTON);
  await tester.ensureVisible(deselectButtonFinder);
  await tester.tap(deselectButtonFinder);

  await tester.pump(Duration.zero);

  final newSelectButtonFinder = find.text(SELECT_BUTTON);
  await tester.ensureVisible(newSelectButtonFinder);
  expect(newSelectButtonFinder, findsOneWidget);
});

9-tests-passed

[/spoiler]

Congratulations! You’re now an official Widget Testing Ambassador, go forth and spread the good news!

Flutter Widget Testing award

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!

Average Rating

5/5

Add a rating for this content

2 ratings

More like this

Contributors

Comments