25  comments

Patrol is a powerful, open-source testing framework created by LeanCode that enhances Flutter's testing capabilities by enabling interaction with native platform features directly in Dart. It allows to handle permission dialogs, notifications, WebViews, and device settings—features previously unavailable in standard Flutter tests, making it truly possible to test the whole app.

This tutorial will take you through writing your first substantial Patrol test, interacting both with the Flutter app itself and also with native permission dialogs and notifications.

Before writing any tests, make sure you install the Patrol CLI. Then just clone the following repository from GitHub to follow along. The app we’re going to be testing is fully functional and ready to be tested, with Patrol already configured.

To learn how to set up Patrol for your own project, check out the Patrol Docs

App Walkthrough

Before we can start writing automated Patrol tests, we need to know what the app does and to test it manually.

The first screen of our app is for signing in. It’s not using any actual sign-in provider but it only validates the email address and password. In order to successfully “sign in” and get to the home screen, we need to input a valid email and a password that’s at least 8 characters long.

You can test any real authentication providers that use WebView for signing in with the powerful Patrol native automation.

On the second screen, we’re greeted with a notifications permission dialog. Once we allow them, we can tap on the notification button in the app bar to manually trigger a local notification which will be displayed after 3 seconds both when the app is running in the foreground or in the background.

Once we open the native notification bar and tap on the notification from our app, we’re gonna see a snackbar on the bottom saying "Notification was tapped!”

Testing the “Happy Path”

You’ve just seen the full walkthrough of the app, including errors that can show up if you input an invalid email or password. UI tests (integration tests), like the ones we’re going to write with Patrol, should only be testing the “happy path” of a UI flow. We only want them to fail if the app suddenly stops the user from doing what the app is for - in this case, that’s displaying a notification. Validation error messages are not “what the app is for”, they exist only to allow the user to successfully sign in with a proper email and password. That’s why we won’t be checking for them in the tests.

Writing the Test

We have only one UI flow in this app, that is signing in, showing the notification and then tapping on that notification. This means, we’re going to have only a single test. Let’s create it in /integration_test/app_test.dart.

Like any other test, we need to have a main() top-level function. Inside it we’re going to have our single patrolTest with a description properly describing what we’re about to test. An optional step is to set the frame policy to “fully live” to make sure all frames are shown, even those which are not pumped by our test code. Without it, we would see that our app stutters and animations are not played properly.

app_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

void main() {
  patrolTest(
    'signs in, triggers a notification, and taps on it',
    framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
    ($) async {
      // Test code will go here
    },
  );
}

We could start writing the test right now and then re-run it from scratch every time we add a new line of test code by calling patrol test --target integration_test/app_test.dart but since we’re writing a Patrol test that runs on an Android or iOS device, constantly building the whole Flutter app is not time effective. Thankfully, Patrol offers a different approach - hot restarting the tests! We can run the command patrol develop --target integration_test/app_test.dart right now and anytime we add a new line of test code, we can just type “r” in the terminal to re-run the tests without the time-costly app building. Just make sure that you have an emulator running first - Patrol will select it automatically.

First, we need to perform any initializations that need to happen before the app is run and pump the top-level widget of our app. We’re effectively doing what the main function inside main.dart does - this time not for just running the app as usual but for running an automated Patrol test.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
  },
);

Hot-restarting the test by typing “r” into the terminal won’t really do much since we’re not yet performing any user-like actions but you will at least see the sign in page for a brief moment before the test finishes.

Let’s now perform some action! We know we have to sign in if we want to continue to the home screen. First, we have to type in both email and password. There are multiple ways to find widgets on the screen - by widget type, by text and lastly by key.

Although it’s not the best practice, we’re first going to find the fields by type. Both are of type TextFormField but there are two of them on the screen so the following won’t work.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(TextFormField).enterText('[email protected]');
    await $(TextFormField).enterText('password');
  },
);

That’s because finders always find the first matching widget so both the email address and password are entered into the same field - in this case, the email field.

If multiple widgets on a screen match the finder, we can tell Patrol which one we want by specifying its index in the list of all found widgets from top to bottom like this:

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(TextFormField).enterText('[email protected]');
    await $(TextFormField).at(1).enterText('password');
  },
);

We can use a text finder to tap on the “Sign in” button.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(TextFormField).enterText('[email protected]');
    await $(TextFormField).at(1).enterText('password');
    await $('Sign in').tap();
  },
);

Hot-restarting the test will now take you all the way to the home page from which we will want to trigger the notification.

As you can imagine though, using type and text finders in any app that’s just a bit more complex will result in a huge mess. The recommended approach is to always find your widgets by their Key. There are currently no keys specified for these widgets so let’s change that. In `sign_in_page.dart` pass in the following into the TextFormFields and ElevatedButton:

sign_in_page.dart

class SignInPage extends StatelessWidget {
  const SignInPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextFormField(
                key: const Key('emailTextField'),
                decoration: const InputDecoration(
                  labelText: 'Email',
                ),
                ...
              ),
              const SizedBox(height: 16),
              TextFormField(
                key: const Key('passwordTextField'),
                decoration: const InputDecoration(
                  labelText: 'Password',
                ),
                ...
              ),
              const SizedBox(height: 16),
              Builder(builder: (context) {
                return ElevatedButton(
                  key: const Key('signInButton'),
                  ...
                  child: const Text('Sign in'),
                );
              }),
            ],
          ),
        ),
      ),
    );
  }
}

With the keys in place, we can now rewrite our test code to use Key finders. The simplest approach is to prefix the key’s value with a hash symbol. For this approach to work, your keys mustn’t contain any invalid characters such as spaces.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
   initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(#emailTextField).enterText('[email protected]');
    await $(#passwordTextField).enterText('password');
    await $(#signInButton).tap();
  },
);

Looking at this test code again, it’s certain we can do better. Why? We’ve just added code duplication to our codebase! The key values in sign_in_page.dart and in app_test.dart are fully duplicated and if we change one, the other won’t be automatically updated, thus breaking our tests.

That’s why production-grade apps should have a single source for all the Keys exposed as a global final variable inside integration_test_keys.dart. That’s going to look as follows if we already take into account the home page which we want to test next.

integration_test_keys.dart

import 'package:flutter/foundation.dart';

class SignInPageKeys {
  final emailTextField = const Key('emailTextField');
  final passwordTextField = const Key('passwordTextField');
  final signInButton = const Key('signInButton');
}

class HomePageKeys {
  final notificationIcon = const Key('notificationIcon');
  final successSnackbar = const Key('successSnackbar');
}

class Keys {
  final signInPage = SignInPageKeys();
  final homePage = HomePageKeys();
}

final keys = Keys();
Feel free to put your page-specific key classes (e.g. SignInPageKeys) into separate files in more complex apps.

The updated sign_in_page.dart code will now look like this:

sign_in_page.dart

class SignInPage extends StatelessWidget {
  const SignInPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextFormField(
                key: keys.signInPage.emailTextField,
                decoration: const InputDecoration(
                  labelText: 'Email',
                ),
                ...
              ),
              const SizedBox(height: 16),
              TextFormField(
                key: keys.signInPage.passwordTextField,
                decoration: const InputDecoration(
                  labelText: 'Password',
                ),
                ...
              ),
              const SizedBox(height: 16),
              Builder(builder: (context) {
                return ElevatedButton(
                  key: keys.signInPage.signInButton,
                  ...
                  child: const Text('Sign in'),
                );
              }),
            ],
          ),
        ),
      ),
    );
  }
}

The test code will now also use the keys global final variable instead of the hash symbol notation:

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(keys.signInPage.emailTextField).enterText('[email protected]');
    await $(keys.signInPage.passwordTextField).enterText('password');
    await $(keys.signInPage.signInButton).tap();
  },
);

Hot-restarting the test won’t show any change in its functionality but it sure is more maintainable and easier to work with.

Home Page

First, let’s add the keys we’ve already created to the IconButton and the SnackBar shown when the notification has been tapped.

home_page.dart

class HomePage extends StatefulWidget {
  ...
}

class _HomePageState extends State<HomePage> {
  ...

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Home'),
        actions: [
          IconButton(
            key: keys.homePage.notificationIcon,
            icon: const Icon(Icons.notification_add),
            onPressed: () {
              triggerLocalNotification(
                onPressed: () {
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      key: keys.homePage.successSnackbar,
                      content: const Text('Notification was tapped!'),
                    ),
                  );
                },
                onError: () {
                  ...
                },
              );
            },
          ),
        ],
      ),
      ...
    );
  }
}

The first thing the user sees when first navigating to the HomePage is a notifications permission dialog. We need to accept it from within the test. Patrol’s native automation makes this as easy as it gets.

Native automation allows you to interact with the OS your Flutter app is running on. Patrol currently supports Android, iOS and macOS native interactions. Learn more from the docs

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(keys.signInPage.emailTextField).enterText('[email protected]');
    await $(keys.signInPage.passwordTextField).enterText('password');
    await $(keys.signInPage.signInButton).tap();
    await $.native.grantPermissionWhenInUse();
  },
);

Hot-restarting the test will work wonderfully the first time, however, once the permission has already been granted, calling grantPermissionWhenInUse() will fail. This is not going to be an issue if you use Patrol as a part of your CI/CD process since everytime you test with Patrol there, the app will be built from scratch and no permission will be granted yet. But when we’re writing the test locally with patrol develop command, we need to make sure that the permission dialog is visible before trying to accept it.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(keys.signInPage.emailTextField).enterText('[email protected]');
    await $(keys.signInPage.passwordTextField).enterText('password');
    await $(keys.signInPage.signInButton).tap();
    if (await $.native.isPermissionDialogVisible()) {
      await $.native.grantPermissionWhenInUse();
    }
  },
);
It’s generally a bad practice to add any branching logic within your tests and you should be 100% certain that it cannot introduce any test flakiness before doing so. Checking if a permission dialog is visible is an example of a proper use of branching logic.

Next up, we want to tap on the notification icon button and then go to the device home screen to test the notification while the app is running in the background.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(keys.signInPage.emailTextField).enterText('[email protected]');
    await $(keys.signInPage.passwordTextField).enterText('password');
    await $(keys.signInPage.signInButton).tap();
    if (await $.native.isPermissionDialogVisible()) {
      await $.native.grantPermissionWhenInUse();
    }
    await $(keys.homePage.notificationIcon).tap();
    await $.native.pressHome();
  },
);

Once we’re on the home screen, we want to open the notification shade and tap on the notification we get from our app. You can either tap on a notification by index or by finding a text. We know that the title of our notification is “Patrol says hello!” so let’s do the latter.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(keys.signInPage.emailTextField).enterText('[email protected]');
    await $(keys.signInPage.passwordTextField).enterText('password');
    await $(keys.signInPage.signInButton).tap();
    if (await $.native.isPermissionDialogVisible()) {
      await $.native.grantPermissionWhenInUse();
    }
    await $(keys.homePage.notificationIcon).tap();
    await $.native.pressHome();
    await $.native.openNotifications();
    await $.native.tapOnNotificationBySelector(
      Selector(textContains: 'Patrol says hello!'),
    );
  },
);

Since the notification is delayed by 3 seconds, we have to provide a timeout that’s at least as long in order to wait for the notification to appear - 5 seconds should do the trick here.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    ...
    await $.native.openNotifications();
    await $.native.tapOnNotificationBySelector(
      Selector(textContains: 'Patrol says hello!'),
      timeout: const Duration(seconds: 5),
    );
  },
);

Lastly, we want to check if the snackbar has been shown after tapping on a notification. We can call waitUntilVisible() after selecting it with its key.

app_test.dart

patrolTest(
  'signs in, triggers a notification, and taps on it',
  framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive,
  ($) async {
    initApp();
    await $.pumpWidgetAndSettle(const MainApp());
    await $(keys.signInPage.emailTextField).enterText('[email protected]');
    await $(keys.signInPage.passwordTextField).enterText('password');
    await $(keys.signInPage.signInButton).tap();
    if (await $.native.isPermissionDialogVisible()) {
      await $.native.grantPermissionWhenInUse();
    }
    await $(keys.homePage.notificationIcon).tap();
    await $.native.pressHome();
    await $.native.openNotifications();
    await $.native.tapOnNotificationBySelector(
      Selector(textContains: 'Patrol says hello!'),
      timeout: const Duration(seconds: 5),
    );
    $(keys.homePage.successSnackbar).waitUntilVisible();
  },
);

And just like that, we have now tested the whole flow of the app with Patrol! If any part of the logic breaks, this test will notify us about that sooner than our real users do and that’s what we’re all after!

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter Developer at LeanCode and a developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

  • I loved as much as you will receive carried out right here. The sketch is attractive, your authored material stylish. nonetheless, you command get got an impatience over that you wish be delivering the following. unwell unquestionably come more formerly again since exactly the same nearly a lot often inside case you shield this hike.

  • Stay ahead of the curve in the Iraqi marketplace with Businessiraq.com. This invaluable online resource is your complete guide to the Iraqi business sector. Detailed online business listings allow for targeted networking and efficient market research. Access cutting-edge business news in Iraq, explore exciting Iraq job opportunities, and secure procurement contracts through the comprehensive tender directory. Businessiraq.com empowers businesses to thrive in the dynamic Iraqi environment.

  • Networking and Collaborative Growth One of the key strengths of Businessiraq.com lies in its ability to facilitate networking among businesses operating in Iraq. The platform not only serves as a directory but also encourages collaborations and partnerships that drive economic growth. By optimizing content related to networking events, industry forums, and trade fairs, Businessiraq.com improves its SEO ranking and attracts a diverse audience interested in expanding their business reach. Users can benefit from connecting with like-minded professionals and exploring new ventures, making Businessiraq.com a vital hub for fostering business relationships within the Iraqi market.

  • Revolutionizing market intelligence in Iraq, Businessiraq.com emerges as a game-changing platform by integrating artificial intelligence and data analytics into its Iraq business directory. The platform’s innovative Smart Match feature automatically connects businesses with compatible partners based on their profiles, industry focus, and business objectives. Real-time business news in Iraq is enhanced with predictive market analysis, helping companies anticipate trends and opportunities. The platform’s Iraq jobs section now includes skill-matching algorithms, while the tender directory offers automated alerts for relevant opportunities. With enhanced online business listings featuring virtual showrooms and 360-degree company tours, Businessiraq.com is transforming how businesses connect and thrive in Iraq’s digital age.

  • Businessiraq.com is your gateway to accessing the full potential of the Iraqi market. This comprehensive Iraq business directory provides extensive online business listings, enabling efficient networking and informed market entry. Discover the latest Iraq business news, explore diverse Iraq jobs, and leverage the tender directory to secure procurement contracts. With Businessiraq.com, you are fully connected to the Iraqi business landscape, facilitating growth and success.

  • Businessiraq.com is your one-stop resource for navigating the Iraqi business landscape. This online directory provides crucial information and connections for businesses looking to engage with the Iraqi market. We offer a comprehensive Iraq Business Directory, meticulously curated to showcase a diverse range of Iraqi companies with detailed profiles. Furthermore, we deliver essential Iraq Business News, keeping you informed about market trends, regulations, and emerging opportunities. This centralized platform allows you to efficiently connect with potential partners, understand market dynamics, and expand your reach within Iraq.

  • Unlock the full potential of Iraq’s dynamic market with businessiraq.com, the region’s most comprehensive and innovative business directory platform. Our trilingual service (Arabic-English-Kurdish) connects over 100,000 verified Iraqi companies with global opportunities through advanced AI-powered matching, real-time market intelligence, and interactive business mapping across all 18 governorates. Whether you’re exploring Iraq’s thriving sectors like Oil & Gas, Construction, Technology, or Healthcare, our platform provides essential tools including live tender updates, trade finance solutions, and detailed company profiles backed by BoldData verification. International investors benefit from our custom market entry strategies, regulatory compliance guidance, and virtual business delegation programs, while local businesses gain unprecedented global exposure and networking opportunities. With ISO 27001 certified security, GDPR compliance, and a proven track record of 25,000+ verified profiles and 1,000+ monthly B2B matches, businessiraq.com stands as your trusted partner in navigating Iraq’s promising business landscape. Join our thriving community today to access exclusive features including personalized business intelligence reports, priority search listings, and premier networking events, all designed to accelerate your success in one of the Middle East’s most promising economies.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >