Migration to Flutter Guide
Discover our battle-tested 21-step framework for a smooth and successful migration to Flutter!

Simplifying Flutter Web Testing: Patrol Web

Piotr Maszota - Flutter Developer at LeanCode
Piotr Maszota - Flutter Developer at LeanCode
Dec 8, 2025 • 10 min.
Piotr Maszota - Flutter Developer at LeanCode
Piotr Maszota
Flutter Developer at LeanCode

Patrol has officially reached version 4.0, and this release marks a big milestone for us. Among the many new features, all nicely listed in the full Patrol 4.0 announcement, one stands out in particular:

Patrol now supports Web!

Flutter Web continues to mature, and many teams now expect the same level of testing capabilities they’re used to on mobile. In this article, we’ll walk through what Patrol Web offers today, but also look behind the scenes: How we designed it, why certain decisions were made, and what it took to bring Patrol’s architecture from mobile straight into the browser.

What motivated us?

Flutter is fast, expressive, and in our opinion, absolutely ready for building large, production-grade applications. But even a few years ago, it had one very noticeable gap: there was simply no solid, Flutter-first framework for true end-to-end testing.

Developers had widget tests. They had unit tests. They even had integration_test. But none of those tools truly reflected how real users interact with an app running on an actual device.

That gap is exactly why Patrol was developed by LeanCode. We wrote more about its origins in one of the articles from the beginning of Patrol, but the short version is simple: Patrol started because Flutter didn’t have the E2E testing solution it deserved.

At first, Patrol focused exclusively on mobile – Android and iOS – because that’s where the majority of the Flutter ecosystem lived at the time. And for mobile, the need was obvious.

Why Web needed a better Flutter-first E2E framework too

As Flutter Web became more widely adopted, we started seeing the exact same issues developers had faced on mobile years earlier.

integration_test limitations on Web

On paper, integration_test looks like a convenient, official solution. In practice, especially on the Web, it comes with a long list of pain points:

  • a complicated setup involving ChromeDriver, bridging scripts, and manual processes;
  • no proper exit code, making CI pipelines… interesting;
  • no native support for running multiple tests in a reliable way;
  • test code locked inside widgetTester, which means no access to browser-level interactions.

That last point is particularly important.

Missing native browser interactions

Real end-to-end scenarios on the Web often require things like:

  • authenticating via a third-party login widget in an embedded iFrame,
  • verifying file downloads,
  • working with cookies,
  • handling native browser dialogs or permission prompts.

None of these actions are possible with integration_test, because you can only interact with Flutter widgets, not with the actual browser environment.

CI challenges

Since flutter drive doesn’t provide proper exit codes, CI pipelines require manually parsing logs to determine whether tests passed. No one should have to do that in 2025.

All of this made it clear: Flutter Web needed a real E2E testing tool – one designed for Flutter, but capable of interacting with the browser like a native Web testing framework.

Why now?

As we explored the idea of bringing Patrol to the Web, it quickly became clear that the timing was perfect.

Flutter Web has finally matured

Flutter Web is no longer the experimental side project it used to be. More and more teams are shipping real, production Web apps in Flutter. The framework itself has progressed dramatically in stability, performance, and tooling.

With this shift came more advanced use cases… which, naturally, require more advanced testing.

Extending Flutter’s philosophy

Flutter’s core promise has always been:

“Write once, run anywhere.”

We love this idea. And Patrol Web felt like the natural continuation of that philosophy:

“Write your Patrol tests once, verify anywhere.”

This is the case whether it’s Android, iOS, macOS, or now Web.

Most upvoted feature request

And, of course, there was one more signal we couldn’t ignore: Patrol Web was the most upvoted issue in our GitHub repository. The community clearly wanted it – and that made the decision easy.

How Patrol’s architecture was migrated to Web

In this part, we’ll take a deeper dive into how we brought Patrol’s architecture – built initially for Mobile and macOS – to Web.

But first, let's take a look at Patrol Web!

screen
circlecrossrectangletrianglesblock

Patrol’s architecture on mobile

Before we move to the Web, let’s quickly recap how Patrol works on mobile platforms. We won’t go into every detail here – that’s already covered in our article about Patrol 2.0. But to understand the challenges of the Web, we need a simplified picture of the mobile architecture.

A quick overview of the mobile architecture

Patrol’s architecture on mobile

On mobile, Patrol works through two cooperating processes:

1. App Under Test

A single process containing two key pieces:

  • App code – your Flutter application
  • Dart tests – written with patrolTest

Inside these tests, you get:

  • WidgetTester → full power of flutter_test
  • NativeAutomator (accessible via $.native) → the ability to perform actions that go beyond Flutter widgets (for example, $.native.tap(), interacting with native pop-ups, and so on)

2. Native Test Runner (Instrumentation / Orchestrator)

A second process is responsible for actually executing the tests.

It consists of:

  • Dynamically generated test cases
    These are created based on the test hierarchy reported by the App Under Test.
  • NativeAutomator
    A platform-specific UI automation layer:
    - XCUITest on iOS
    - UIAutomator on Android

This is what allows Patrol to interact with fully native UI elements, such as system dialogs or even launching other apps (e.g., Safari on iOS).

Communication between processes

Because the App Under Test and the Native Test Runner run in separate processes*, they have to communicate with each other somehow. On iOS, Android, and macOS, this communication happens over HTTP on localhost.

*We’ll come back to this distinction when we look at how Patrol works on Web.


1. Generate the native build configuration

The process starts with:

flutter build <platform> --config-only

This doesn’t compile the app; it only generates the native configuration needed by tools like Xcode or Gradle. It’s essentially preparing the project so the platform build system knows how to build it for testing.

2. Build the app in “for testing” mode
With the configuration ready, Patrol triggers the platform’s native build process. This produces a test-ready build of your Flutter application: a native bundle that includes everything required by the system’s UI testing framework.

3. Running the tests
The compiled app is then used in the native test process, which is described in the picture above.


The Discovery Phase

One more essential piece of the architecture is the Discovery Phase, which is arguably one of the trickiest parts.

The test_api package does not provide a simple getTestsHierarchy() function. (We wish it did – life would be much easier.)

So Patrol uses a clever workaround:

  1. Launch the app for the first time.
  2. Execute all tests, but with their bodies removed.
  3. After this “dry run”, the whole test hierarchy is accessible through the Invoker object from test_api.
  4. Patrol reads this hierarchy and sends it to the Native Test Runner, which recreates the same structure on the native side.

The result?

  • structured, native-quality test reports,
  • compatibility with device farms using XCUITest or Espresso,
  • stable, predictable test execution sequences.

Patrol’s architecture on Web

Now that we have the mobile picture in mind, let’s explore how we mapped that architecture to Web.

App Under Test

This part turned out to be relatively straightforward. Most of the Flutter-side testing logic – test bundling, patrol_finders, and everything that happens inside Flutter itself – works the same on Web as it does on mobile. Because of this, we were able to reuse this portion of the architecture with minimal adjustments, without redesigning it from scratch.

Native Test Runner on the Web

This is where things start to differ. Unlike mobile (where each platform has an official automation framework), the Web ecosystem offers dozens of tools for UI automation. The first candidate we tried – which turned out to be perfect – was Playwright.

Why Playwright?

  • It’s modern and widely usedadopted
  • Its API for browser interactions is extremely advanced
  • It handles everything Flutter cannot:
    - alert dialogs
    - iFrames
    - permission prompts
    - downloads
    - keyboard inputs
    - clipboard
    - and much more

Communication: Dart ↔ JavaScript

Earlier, we mentioned that on mobile Patrol works through two separate processes. Because of that separation, the only practical way for them to communicate is over HTTP on localhost.

On the Web, the situation looks different.

The Flutter app runs inside the browser, and the test runner (Playwright) interacts with that same browser environment. This means we no longer need a network-based communication layer.

Instead, Patrol uses Dart’s js_interop to make communication possible on both sides. With a few annotations, we can expose selected Dart methods to JavaScript and call JavaScript functions from Dart when needed, all without additional servers or custom protocols.

This keeps the communication layer on the Web much simpler than the one used on mobile.


Discovery Phase on Web

To build the test hierarchy, Patrol still needs a Discovery Phase – the same concept we use on mobile. The question was how to run it in the browser.

Here, Playwright, again, turned out to be a good fit. Its configuration supports a globalSetup parameter, and we use exactly that spot to run the Discovery Phase.
During globalSetup, we:

  • launch the Flutter Web app in discovery mode,
  • call the Dart-side API to retrieve the list of tests,
  • and pass that list to Playwright so it can generate the actual test cases.

Thanks to this, the whole process integrates cleanly with Playwright’s existing workflow. All tests – discovery included – can be run using a standard:

npx playwright test

Instead of managing two separate custom scripts (one for discovery, one for running tests), everything now happens through Playwright’s built-in mechanisms.

Starting the app on Web

On mobile, Patrol needs a fully compiled, platform-native executable of the app. That binary is then used by the native test frameworks (XCUITest / UIAutomator) when the test process starts.

On the Web, the flow is different.

Playwright doesn’t run an executable file; it simply opens a URL where the app is hosted.
So instead of building the app first and then serving it locally, we use a shortcut provided by Flutter:

flutter run -d web-server

This command both builds the Web version of the app and exposes it on a local HTTP server.

Patrol reads the port from Flutter’s logs and uses it as the baseURL for Playwright. Then, when Playwright starts a test, it just navigates to that URL and begins interacting with the app.

That’s all that’s needed – no separate build step, no manual hosting.

Architecture comparison table

MobileWeb
Test Runner
Test Runner
Test Runner
Native test frameworks (XCUITest on iOS, UIAutomator/Espresso on Android) execute the tests against a built app bundle.Playwright runs the tests directly in the browser environment.
Starting the App
Starting the App
Starting the App
The app is built into a native executable using: • flutter build <platform> --config-only• native build tools (e.g., xcodebuild build-for-testing). The resulting bundle is used during test execution. The app is served over HTTP using: • flutter run -d web-server Playwright simply navigates to the generated local URL.
Discovery Phase
Discovery Phase
Discovery Phase
The app is launched once in discovery mode, runs all tests without their bodies, and reports the hierarchy through test_api’s Invoker. The runner uses this hierarchy to generate native test cases.The Discovery Phase runs inside Playwright’s globalSetup. It starts the Web app in discovery mode, retrieves the Dart test list using JS ↔ Dart interop, and provides that list to Playwright.
Communication Between Tests and Runner
Communication Between Tests and Runner
Communication Between Tests and Runner
Two separate processes communicate over HTTP on localhost, because Flutter and the native runner are isolated.The Flutter app and test runner operate inside the browser environment. Communication happens via Dart js_interop, allowing Dart and JS to call each other directly.
MobileWeb
Test Runner
Test Runner
Test Runner
Native test frameworks (XCUITest on iOS, UIAutomator/Espresso on Android) execute the tests against a built app bundle.Playwright runs the tests directly in the browser environment.
Starting the App
Starting the App
Starting the App
The app is built into a native executable using: • flutter build <platform> --config-only• native build tools (e.g., xcodebuild build-for-testing). The resulting bundle is used during test execution. The app is served over HTTP using: • flutter run -d web-server Playwright simply navigates to the generated local URL.
Discovery Phase
Discovery Phase
Discovery Phase
The app is launched once in discovery mode, runs all tests without their bodies, and reports the hierarchy through test_api’s Invoker. The runner uses this hierarchy to generate native test cases.The Discovery Phase runs inside Playwright’s globalSetup. It starts the Web app in discovery mode, retrieves the Dart test list using JS ↔ Dart interop, and provides that list to Playwright.
Communication Between Tests and Runner
Communication Between Tests and Runner
Communication Between Tests and Runner
Two separate processes communicate over HTTP on localhost, because Flutter and the native runner are isolated.The Flutter app and test runner operate inside the browser environment. Communication happens via Dart js_interop, allowing Dart and JS to call each other directly.

To sum it up: the core ideas behind Patrol’s mobile architecture carry over to the Web, but the environment changes the way they’re implemented. On mobile, we rely on native binaries, native test runners, and process-to-process communication. On the Web, everything happens in the browser, and Playwright becomes the orchestration layer.

Adding a third platform made it clear that the previous approach to “native” interactions didn’t scale well. $.native and $.native2 worked for iOS and Android, but supporting native-level interactions in a platform that differs so fundamentally from mobile exposed the limits of that design. We needed an API that could express platform-specific differences more naturally and grow cleanly with each new platform.

And that’s exactly what led us to redesign the API – introducing $.platform.

From $.native to $.platform – rethinking how Patrol talks to each platform

While working on Patrol Web, we noticed something important: supporting a third platform wasn’t just about adding new code, it required rethinking how Patrol exposes platform interactions in the first place.


The original API ($.native) was created when Patrol supported only iOS and Android. Later, $.native2 improved precision, but still assumed that platforms share a similar set of actions. Once the Web appeared, that assumption no longer held up.

This led us to introduce a new API layer: PlatformAutomator, available under $.platform.
But before we get to that, let’s briefly look at how the API evolved.

$.native – finding a common ground

In the very first versions of Patrol, we wanted writing native interactions to feel as unified as possible. The goal was simple: one API, two platforms.

For example:
$.native.tap(Selector(text: "Press me"));

And it worked – on both iOS and Android, the result was the same: tapping text with “Press me”. But behind the scenes, the two platforms describe their UI nodes using completely different attributes. Despite that, we chose to build a single, shared Selector (based mainly on Android naming), hoping it would be “good enough” in most cases.

It was good… until it wasn’t.

As apps grew more complex and test cases required more precision, the abstraction started to leak.

$.native2 – accepting the differences

The next step was acknowledging that Android and iOS have different accessibility attributes, different node structures, and different ways to identify elements.

So in NativeAutomator2, we:

  • kept one set of native actions
  • but introduced platform-specific selectors:

IOSSelector(...)

AndroidSelector(...)

This allowed for much more accurate targeting of UI elements. It solved a big part of the problem, but not all of it.

Even with separate selectors, we still treated the set of native actions as something mostly “shared”. But that assumption fell apart once we started thinking seriously about the Web.

$.platform – embracing platform differences

When we looked at what “native interactions” even mean on the Web, it became clear that the action sets differ far more than just in terms of the Selector.

Web has interactions like:

  • handle browser dialogs,
  • interact with iFrames,
  • enter combo key.

These don’t map cleanly to either iOS or Android. So instead of trying to force a common API across all platforms, we decided to do the opposite: make the differences explicit.

Each platform now exposes only the actions it actually supports, through its own namespace:

$.platform.android.pressBack();

$.platform.ios.closeHeadsUpNotification();

$.platform.web.copyToClipboard();

Shared actions – things that truly exist on both mobile platforms – live under:

$.platform.mobile; (like .pressHome(), .tapOnNotification())

Moreover, a few actions that conceptually exist everywhere (even if the implementation differs) remain available at the top level:

$.platform.tap();

This makes it impossible to accidentally call iOS-only methods on Web or Android, and vice versa. The API now represents real capabilities of real platforms, nothing more, nothing less.

$.platform.action.maybe – handling platform differences in test logic

In the old API, writing platform-specific logic required branching manually:

if (Platform.isIOS) {
  ...
} else if (Platform.isAndroid) {
  ...
}

The new API introduces a cleaner, declarative way to express this:

await $.platform.action.maybe(
  web: () => $.platform.web.acceptNextDialog(),
  ios: () => $.platform.ios.doubleTap(IOSSelector(text: 'OK')),
  android: () => $.platform.android.tap(
    AndroidSelector(className: 'android.widget.Button'),
  ),
);


It keeps test code readable and makes platform-specific behavior explicit rather than buried inside conditionals.

Where this leaves us

Bringing Patrol to the Web didn’t just add a new platform – it pushed us to rethink how platform interactions should work in general.


$.native was a good start, and $.native2 improved precision, but the Web made it clear that each platform exposes a fundamentally different set of interactions. Trying to force all of them into a single, unified abstraction no longer made sense.

$.platform embraces these differences and provides a structure that naturally fits all three environments. It gives each platform its own dedicated surface area, while still allowing shared actions where that actually makes sense.

As part of this shift, both $.native and $.native2 are now marked as deprecated in Patrol 4.0 and will be removed in a future release. If you encounter any issues while migrating to the new API – or notice regressions in behavior – please let us know by opening an issue in the Patrol repository:

https://github.com/leancodepl/patrol/issues

With the new API in place, we can continue expanding Patrol’s capabilities without forcing platforms into abstractions that don’t fit them and without holding back features that are specific to Web, Android, or iOS.

Feature overview

With the architectural groundwork and API changes in place, it’s a good moment to focus on what Patrol Web actually delivers today. This section provides a concise overview of the features available on Web.

Same tests, new platform

One of the strengths of Patrol Web is that most tests don’t need to change at all when running in a browser. You still write them with patrolTest, use the same finders, and rely on the same Dart testing APIs – just as you do on mobile.

Running them on Web simply means pointing the CLI at a browser:

patrol test --device chrome

Your high-level test logic remains the same. (Only native interactions may require adjustments, since Web has its own set of platform-specific actions.)


Browser-native interactions with $.platform.web

When your test needs to interact with parts of the page that aren’t Flutter widgets, you can switch to $.platform.web. This API exposes browser-native actions in a typed, Dart-friendly form, letting you work directly with behaviors that exist only in the browser environment.

Examples include:

  • switching between light and dark mode,
  • resizing the browser window,
  • navigating browser history,
  • sending key presses and key combinations (for example, Ctrl+A or Cmd+C),
  • reading from and writing to the clipboard,
  • performing scroll, focus, or visibility actions outside the Flutter widget tree,
  • adding, inspecting, and clearing cookies,
  • uploading virtual files directly from memory,
  • verifying file download during a test run

In practice, this gives you the capabilities of modern browser automation while staying entirely in Dart and the Patrol ecosystem.

If you want to see the full list of Web-specific methods, the documentation provides an up-to-date reference of everything available under $.platform.web.

The list of $.platform.web methods in the Patrol docs

Web-aware CLI flags

When running tests on Web, Patrol exposes a set of --web-* flags that are passed directly to Playwright. These options give control over stability, execution speed, reporting, and environment simulation - all from the same patrol test --device chrome command.

Execution & stability

Parameters that help you tune how tests are executed, how long they can run, and how failures are retried:

  • --web-retries -  Number of times to retry failed tests
  • --web-timeout -  Maximum time in milliseconds for single test execution
  • --web-global-timeout - Maximum total time in milliseconds for the entire test run
  • --web-workers -  Maximum number of parallel worker processes for test execution.

Reporting & debugging

Flags that control what Patrol records, how the results are reported, and whether tests run in headless or headed mode:

  • --web-video - Video recording mode. For example, you can set it to only retain the recording of failed tests. 
  • --web-reporter - Test reporters to use. Test reports might be written in html, json or xml.
  • --web-results-dir
  • --web-report-dir
  • --web-headless

Environment configuration

These options allow tests to simulate different regions, themes, permission sets, and viewport sizes from the command line:

  • --web-locale
  • --web-timezone
  • --web-color-scheme
  • --web-geolocation
  • --web-permissions
  • --web-user-agent
  • --web-viewport

CI integration

You can use Patrol to perform web tests on the CI and get the native report. Just make sure to add --web-headless flag to the patrol test command. You can also get the recording of a failed test if you also provide --web-video=retain-on-failure.

The first execution installs the necessary Playwright dependencies, and all subsequent runs reuse them automatically. From there, you can forward standard Playwright reporting flags to generate HTML reports, videos, and other diagnostics.

This makes it straightforward to build CI workflows that exercise your Web app end-to-end, produce clear diagnostic output, and include video recordings for failed tests - all with the same testing approach you already use on mobile.


Develop mode on Web

On mobile, Patrol offers a develop mode – a fast feedback loop that lets you iterate on tests and application code without rebuilding native binaries. You can edit a test, save the file, and re-run it almost instantly, with full support for native interactions. It’s a highly productive way to write and debug end-to-end tests.

Naturally, we want the same experience on the Web. The overall architecture is already in place, and most of the building blocks we need are working. But there’s one issue that currently prevents develop mode from behaving the way it should.

Flutter’s hot restart on the Web does not pick up changes made to test files. Even though the application itself restarts, the updated test code isn’t reloaded, meaning the test still runs with the previous version of the file. The problem is tracked here:

https://github.com/flutter/flutter/issues/175318


Until this is resolved at the framework level, we can’t offer a reliable develop mode for Patrol Web.

The good news is that building a Web application is significantly faster than building native binaries, so the feedback loop is still reasonably quick even without develop mode. Once Flutter enables the reloading of modified test sources during hot restart, adding full develop mode support for the Web should be straightforward.

The feature overview

Patrol Web is already fully usable in version 4.0, and we encourage you to try it out in your own projects. If you haven’t yet experimented with running your tests on Chrome, this is a great moment to start – the setup is simple, the API is familiar, and the surface area is already broad enough to cover real end-to-end scenarios.

We’ll continue showcasing concrete examples of the features described above on the Patrol X account: follow Patrol here. That’s also the first place where we’ll announce full support for develop mode on the Web once the underlying Flutter issue is resolved.

Meet our expert

How to automate E2E testing in Flutter with Patrol 4.0?

Mateusz Wojtczak / Head of Mobile at LeanCode
Mateusz Wojtczak, Head of Mobile at LeanCode
In this webinar, our Head of Mobile discusses the existing end-to-end (E2E) testing solutions available on the market - their advantages, disadvantages, and limitations. You'll also learn how to set up your E2E testing environment with Patrol 4.0 and how to avoid common mistakes.
Mateusz Wojtczak
Head of Mobile at LeanCode
Mateusz Wojtczak, Head of Mobile at LeanCode

The business value of Patrol Web

Patrol Web isn’t just an extension of the framework to another platform; it also brings a set of very practical benefits for teams shipping real products in Flutter. Once tests can run on Web and mobile from the same codebase, several things become noticeably easier.


1. Reduced QA Effort

Without Patrol Web, teams often end up maintaining separate test suites for Web and mobile. That doubles the work, increases the chance of inconsistencies, and slows down releases. With Patrol Web, the same high-level test scenario login, onboarding, payments, file handling, whatever your app does, can be reused across platforms. This significantly reduces the long-term cost of keeping E2E tests healthy.

2. Faster delivery cycles

Running identical test scenarios on multiple platforms in parallel speeds up feedback loops. Issues that previously surfaced late in the process, often only during manual Web QA, can now be caught together with mobile regressions. Shorter QA cycles mean faster releases and fewer surprises near the finish line.

3. Higher confidence in cross-platform quality

One of the painful realities of cross-platform development is the “works on mobile, breaks on Web” class of bugs. When the same test suite validates the same flows across iOS, Android, and Web, regression coverage improves naturally. This leads to more predictable releases, fewer platform-specific edge cases, and a generally smoother development process.

Patrol Web, your solution for Flutter Web testing


As Flutter on the Web grows, the gaps in the integration_test package – no sharding, no isolation, no native interactions, no clean reports – slow teams down and increase QA costs.

On mobile, Patrol has already solved these problems with its graybox architecture:  a separate instrumentation process, tests running inside the real app, dynamic discovery, HTTP-based communication, and full access to the widget tree plus native actions.

With Patrol 4.0, we rethought this model for the Web, selecting a new test runner, rebuilding PlatformAutomator, and connecting Dart to the browser through js_interop. Now, you can finally utilize Patrol’s power on the Web: faster releases, shared test suites across platforms, and higher confidence in every build.

If you need help setting up Patrol (not just Web) in your project or using the full potential of our framework, the LeanCode team, creators of Patrol, offers dedicated support services like Patrol Setup & Patrol Training and Automating QA processes in your Flutter application.

Rate this article
Star 1Star 2Star 3Star 4Star 5
5.00 / 5 Based on 3 reviews

You may also like

Announcing Patrol 4.0 by LeanCode

How Patrol 4.0 Makes Cross-Platform Flutter Testing Possible

Patrol 4.0 is here! By far the biggest update since improvements were made to the test building, it brings support for a web platform, a VS Code extension, a better debugging experience, and many smaller improvements. Let’s dive into the details and the brief backstory behind this release.

Patrol VS Code Extension developed by LeanCode

Patrol VS Code Extension - A Better Way to Run and Debug Flutter UI Tests

We are excited to announce the release of the official Patrol extension for Visual Studio Code! It brings the power of Patrol directly into your IDE, transforming how you write, run, and debug Patrol tests. Read this article to find out more about how it works.

Testing SMS in Automated Tests by LeanCode

Testing SMS in Automated Tests for Optimal Delivery

Testing SMS often seems like a simple task - until you try to automate it. Each message depends on external networks, carriers, and devices, making even small tests unpredictable. This article explains how to choose the right approach for stable and reliable results.