Letting an AI agent test your Flutter app

How to let an AI agent test your Flutter app with Marionette, an MCP server that taps, types and screenshots a debug build, plus the keys and habits that help.

By Hamza Ghouri

An AI coding agent can write a Flutter screen in minutes, and then it tells you the screen works because the code looks right. Reading widget code proves nothing about what a user sees. What I wanted was for the agent to test the Flutter app the way a person would: launch it, tap through the flow, type into fields, look at the result and read the logs. Marionette does that, and I recently added it to my team's conventions as the standard way an agent verifies Flutter work before calling it done.

What Marionette is

Marionette is an open-source MCP server that connects an AI agent to a Flutter app running in debug mode. Once connected, the agent gets a small set of tools: list the interactive elements on screen, tap, enter text, scroll to an element, swipe, take screenshots, read the app's logs, and hot reload or hot restart. It is to a Flutter app roughly what Playwright is to a web page.

If MCP is new to you, my explainer on the Model Context Protocol covers how an agent discovers and calls tools like these. The short version: the agent sees each tool with a description and arguments, and decides when to call it.

Setting it up

There are two halves. The app needs a binding that exposes it to Marionette, and your machine needs the MCP server registered with your agent.

In the app, add marionette_flutter as a normal dependency (it is imported from main.dart, so dev_dependencies will not work) and initialise its binding in debug builds only:

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:marionette_flutter/marionette_flutter.dart';

void main() {
  if (kDebugMode) {
    MarionetteBinding.ensureInitialized();
  } else {
    WidgetsFlutterBinding.ensureInitialized();
  }
  runApp(const MyApp());
}

// Every control a flow touches gets a stable key.
Widget submitButton(VoidCallback submit) => ElevatedButton(
      key: const ValueKey('login_submit_button'),
      onPressed: submit,
      child: const Text('Sign in'),
    );

The kDebugMode guard is the important part. A release build should never carry a binding that lets something outside the app drive it.

On the machine, install the server with dart pub global activate marionette_mcp and register it with whichever agent tool you use. Then the loop is: flutter run in debug mode, copy the VM service URI it prints, and have the agent call connect with it.

Keys are what make an app testable

Marionette finds widgets by their ValueKey<String> or by their visible text. Text works for a quick poke, but it breaks the moment someone changes a label or the app runs in another language. Keys survive both, so our convention is that every control a flow touches gets one, named <screen>_<element>: login_submit_button, add_vehicle_registration_field.

A few rules came out of putting this into practice:

  • Key repeated controls by screen and id. Tabs in an indexed stack stay mounted offstage, and a pushed route keeps the screen beneath it mounted. The same bare key can exist twice in the tree, and the agent may match the wrong one. discover_heart_7 and watchlist_heart_7 never collide.
  • Add a key before reaching for configuration. Standard Material widgets show up as interactive out of the box. Some widgets are missing from Marionette's built-in list, but they become tappable once they carry a ValueKey<String>.
  • Never tap by coordinates. If the agent cannot find a widget, the fix is a key in the source.

This is also a good test of the codebase itself. A screen where nothing has a key is a screen nobody planned to test.

The verification loop I ask for

Every Flutter ticket in our conventions is verified through Marionette before it is marked done. The steps are fixed so the agent cannot shortcut them:

  1. Connect to the running debug build.
  2. Call get_interactive_elements before acting, so the agent works from what is actually on screen.
  3. Drive the happy path plus the one failure a user is most likely to hit. For a bug fix, drive the exact path that was broken.
  4. Take a screenshot at the end of each step, and read the logs even when the screen looks right. A silent exception behind a correct-looking screen is still a bug.
  5. Report exactly which steps were driven, and which were not.

Between edits, hot_reload keeps the app's state so the flow can continue where it was. hot_restart starts the Dart code from main() again, which is what you need for changes to bootstrap code or app-wide state. Reset the data before a verification run, because a flow proven against leftover state proves nothing.

The agent writing the code and the agent checking it can be the same session, so I treat its report the same way I treat AI-generated tests: useful evidence, still reviewed by a person.

What the agent cannot see

A full Marionette pass over an app taught us where the edges are. These are worth knowing before you trust a green report:

  • Native UI is out of reach. The share sheet, a browser opened by a link, permission dialogs: Marionette drives the Flutter view only. Those flows get reported as not exercised.
  • Screenshots show the Flutter view only. The status bar and the keyboard are missing. When either matters, take a simulator screenshot instead.
  • Network images can miss the first screenshot. Take another and check the logs before calling an image broken.
  • Hot restart keeps on-disk storage. That makes it a good test of persistence: saved settings must survive it. A true first-launch state needs the app uninstalled or an app-level reset.
  • Headless is smoother. Booting the iOS simulator without opening its window and running flutter run in the background with output going to a log file lets the agent read the VM service URI from the log.

Demo walkthroughs from the same tools

The same connection can record a product walkthrough. Our recipe puts a small overlay in the app, debug builds only, that draws captions and highlights the next tap target, and registers it as extra Marionette extensions. A short driver script then calls those extensions plus Marionette's tap and text entry in a fixed order, at a steady pace, while the simulator records the screen.

The lesson there: never record by having an agent call the tools by hand. The pacing comes out uneven, far too fast to follow, and there is no script to re-run when something changes. Rehearse the script quickly first, then record it at tutorial speed.

Where I'd start

Add the debug-only binding, put ValueKeys on the controls of one important flow, and have your agent drive that flow end to end with screenshots and logs. Once it works for one flow, write the rules down where every agent reads them; I describe how we do that in machine-readable team conventions for AI agents.

Want this kind of work on your product?

Get in touch