Skip to main content
Over the past months, we’ve written many Patrol tests and often learned the hard way what works well and what doesn’t. We’re sharing our findings hoping that they’ll help you write robust tests.
This document follows RFC 2119.

Finding Widgets

PREFER using keys to find widgets

Patrol’s custom finders are very powerful, and you might often be inclined to find the widget you want in a variety of ways. While we’re encouraging you to explore and play with Patrol’s custom finders, we are quite confident that keys are the best way to find widgets.
At first, strings might seem like a good way to find widgets.They’ll get increasingly annoying to work with as your app grows and changes, for example, when the strings in your app change.Using strings stops making any sense when you have more than 1 language in your app. Using strings in such case is asking for trouble.
There are 2 problems with using classes.First is that they hurt your test’s readability. You want to tap on the login button or enter text into the username field. You don’t want to tap on, say, the third button and enter text into the second text field.The second problem is that classes are almost always an implementation detail. As a tester, you shouldn’t care if something is a TextButton or an OutlineButton. You care that it is the login button, and you want to tap on it. In most cases, that login button should have a key.

Example: Bad Practice

await $(LoginForm).$(Button).at(1).tap(); // taps on the second ("login") button
This works, but the code is not very self-explanatory. To make it understandable at glance, you had to add a comment.

Example: Good Practice

If you assigned a key to the login button, the above could be simplified to:
await $(#loginButton).tap();
Much better!
Let’s see another example of why classes are fragile:
await $(Select<String>).tap(); // taps on the first Select<String>
If the type parameter is changed from String to, for example, some specialized PersonData model, that finder won’t find anything. You’d have to update it to:
await $(Select<PersonTile>).tap();
You had to change your test, even though nothing changed from the user’s perspective. This is usually a sign that you rely too much on classes to find widgets.
Have tester’s mindsetTreat your finders as if they were the tester’s eyes. Focus on what the user sees and interacts with, not on implementation details.

CONSIDER having a file where all keys are defined

The number of keys will get bigger as your app grows and you write more tests. To keep track of them, it’s a good idea to keep all keys in, say, lib/keys.dart file.
lib/keys.dart
import 'package:flutter/foundation.dart';

typedef K = Keys;

class Keys {
  const Keys();

  static const usernameTextField = Key('usernameTextField');
  static const passwordTextField = Key('passwordTextField');
  static const loginButton = Key('loginButton');
  static const forgotPasswordButton = Key('forgotPasswordButton');
  static const privacyPolicyLink = Key('privacyPolicyLink');
}
Then you can use it in your app’s and tests’ code:
In app UI code
@override
Widget build(BuildContext context) {
  return Column(
    children: [
      /// some widgets
      TextField(
        key: K.usernameTextField,
        // some other TextField properties
      ),
      // more widgets
    ],
  );
}
In app test code
void main() {
  patrolTest('logs in', (PatrolIntegrationTester $) {
    // some code
    await $(K.usernameTextField).enterText('CoolGuy');
    // more code
  });
}
This is a good way to make sure that the same keys are used in app and tests. No more typos!

Test Structure

PREFER having one test path

Good tests test one feature, and test it well (this applies to all tests, not only Patrol tests). This is often called the “main path”. Try to introduce as little conditional logic as possible to help keep the main path straight. In practice, this usually comes down to having as few ifs as possible. Keeping your test code simple and to the point will also help you in debugging it.
Exception: Permission dialogsIt’s generally a bad practice to add any branching logic within your tests. However, checking if a permission dialog is visible is an example of a proper use of branching logic:
if (await $.platform.mobile.isPermissionDialogVisible()) {
  await $.platform.mobile.grantPermissionWhenInUse();
}
This is acceptable because permission dialogs may already be granted during local development with patrol develop.

DO add a good test description explaining the test’s purpose

If your app is non-trivial, your Patrol test will become long pretty quickly. You may be sure now that you’ll always remember what the 200 line long test you’ve just written does and are (rightfully) very proud of it. Believe us, in 3 months you will not remember what your test does. This is why the first argument to patrolTest is the test description. Use it well!
import 'package:awesome_app/main.dart';
import 'package:patrol/patrol.dart';

void main() {
  patrolTest(
    'signs up for the newsletter and receives a reward',
    ($) async {
      await $.pumpWidgetAndSettle(AwesomeApp());

      await $(#phoneNumber).enterText('800-555-0199');
      await $(#loginButton).tap();

      // more code
    },
  );
}

Additional Best Practices

Test the “Happy Path”

UI tests (integration tests), like the ones we’re writing with Patrol, should focus on 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.
Validation error messages are not “what the app is for” - they exist only to allow the user to successfully complete their task. Don’t test validation errors in integration tests; use unit tests for that instead.

Use Meaningful Variable Names

When working with keys or complex finders, use descriptive variable names that make the test’s intent clear:
// Good
await $(keys.signInPage.emailTextField).enterText('[email protected]');

// Bad
await $(keys.sp.etf).enterText('[email protected]');

Keep Tests Independent

Each test should be able to run independently without relying on the state from previous tests. Always start with a clean slate by pumping your app widget at the beginning of each test.
patrolTest('test description', ($) async {
  await $.pumpWidgetAndSettle(MyApp());
  // rest of test
});

Build docs developers (and LLMs) love