Automated regression testing for Flutter, driven from outside the app with real pointer events. Widgets are found by what is on screen (their text, key, tooltip or semantics label), so an app does not have to be modified, wrapped or annotated before it can be tested.
🤖 AI-Powered Testing: Pair with self_test_mcp to enable Claude and other AI agents to test your Flutter apps with Playwright feature parity - works on iOS, Android, Web, and Desktop!
- Universal locators - Address a widget by the text it paints, its key, its tooltip, its semantics label or its type. No wrapper, no annotation, no change to the app
- Real pointer events - Taps, drags, scrolls and long presses go through
GestureBinding, so hit testing runs and a button behind a dialog is not reachable - Real text entry - Typing goes through the same method the soft keyboard calls, so input formatters run and a form validates what was actually typed
- Runtime Testing - Run tests in live app environments without external test frameworks
- Text Assertions - Built-in text validation for input fields
- Memory Safe - Automatic registration/unregistration prevents memory leaks
- Hot Restart Compatible - Works seamlessly with Flutter's hot restart
- Test Scenarios - Define and run multi-step test scenarios with
TestScenario
- 60+ Playwright-Equivalent Tools - Full feature parity with Playwright browser testing
- State Management Inspection - Inspect and modify Riverpod, Bloc, and Provider state
- Network Mocking - Mock HTTP responses, block requests, monitor traffic
- Visual Regression - Golden file comparison for UI testing
- Platform Mocking - Mock GPS, permissions, platform channels, sensors
- Cross-Platform - Works on iOS, Android, Web (with or without bridge), Desktop
- Bridgeless Web Testing - Test any Flutter web app via Playwright + semantics tree
Add to your pubspec.yaml:
dependencies:
self_test: ^0.2.0Requires Flutter 3.35.0 or newer (Dart 3.9.0). That is the oldest version the test suite runs against in CI, not a guess.
Then run:
flutter pub getThe core package has no dependencies beyond Flutter and meta. The
build_runner code generator is a separate, optional package
(self_test_gen), so the analyzer and formatter never end up in your app's
dependency tree. See Code Generation.
For AI agent integration with Claude Code:
-
Install MCP server:
git clone https://github.com/loonix/self_test cd self_test/packages/self_test_mcp npm install && npm run build ./scripts/setup-claude.sh
-
Add bridge to your app:
dependencies: self_test_bridge: git: url: https://github.com/loonix/self_test path: packages/self_test_bridge
See Architecture section below for details.
self_test can read the entire widget tree, tap anything, type anything and photograph the screen. That is the product in a debug build and a remote control in a shipped one, so:
- It is inert in a release build. Every action and every query returns
nothing or throws. Opt in explicitly with
SelfTestManager.enableInReleaseBuilds()if a device farm needs to drive a signed build. - The bridge listens on loopback only, and refuses to start in a release
build. Pass
host: InternetAddress.anyIPv4to reach it from a real device and accept that the network can reach it too. - The bridge requires a token, generated per instance and printed at
startup, presented as
?token=or anx-self-test-tokenheader. A wrong token gets a 403 before the WebSocket upgrade.
final bridge = SelfTestBridge(); // loopback, random token
await bridge.start();
debugPrint(bridge.url); // ws://127.0.0.1:9999?token=...Pass your own token when nobody is reading a console. The generated one is
announced through debugPrint, which reaches you only while you are attached
to flutter run. Drive the app the way CI does, flutter build then
xcrun simctl launch or adb shell am start, and that line goes nowhere:
not to the launch console, not to the device log. The token is then unknowable
and every connection gets a 403.
final bridge = SelfTestBridge(
token: const String.fromEnvironment('SELF_TEST_TOKEN'),
);Give the same value to the MCP server and the two agree without anyone having to read it off a screen.
flutter pub add dev:self_testtest/login_test.dart, complete and copy-pasteable:
import 'package:flutter_test/flutter_test.dart';
import 'package:self_test/self_test.dart';
import 'package:your_app/main.dart';
void main() {
final app = SelfTestManager();
testWidgets('a user can sign in', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
// "Username" is the label beside the field, which is how a person names
// it. self_test resolves from the label to the field it belongs to.
await app.typeInto(const SelfTestLocator.text('Username'), 'ada');
await app.typeInto(const SelfTestLocator.text('Password'), 'correct horse');
await app.tap(const SelfTestLocator.text('Sign in'));
await tester.pumpAndSettle();
expect(app.exists(const SelfTestLocator.text('Welcome, ada')), isTrue);
});
}flutter test test/login_test.dartThat is the whole quickstart. MyApp is your app, unchanged: no
SelfTestRoot, no SelfTestableWidget, no annotations, no generated code.
This exact flow runs in CI on every push as test/no_wrapper_test.dart,
against an app that imports nothing from this package.
Nothing above needs a change to the app. The locator is resolved against the
element tree at the moment it is used, and the tap is a real
PointerDownEvent and PointerUpEvent through GestureBinding, so hit
testing runs: a button under a dialog is not reachable, a disabled button
swallows the tap, and typing goes through the field's input formatters.
Locators, in the order you will reach for them:
| Locator | Finds |
|---|---|
SelfTestLocator.text('Sign in') |
the text a widget paints (exact: false for a substring) |
SelfTestLocator.key('submit') |
a ValueKey<String> |
SelfTestLocator.tooltip('Delete') |
an icon-only button |
SelfTestLocator.semantics('Avatar') |
what a screen reader would read |
SelfTestLocator.type('Switch') |
a widget type by name |
SelfTestLocator.id('login_button') |
a SelfTestableWidget id |
Add .at(2) to any of them to pick between duplicates, in tree order.
describeScreen() answers "what can I do here?" without a locator at all: it
returns every actionable widget on screen with its type, text, tooltip, rect
and enabled state. That is what the MCP server hands an agent.
Over the bridge there are also getByText and getByRole, for callers who
arrive with Playwright's vocabulary. getByRole('button', name: 'Sign in')
maps the role onto the Flutter types that answer to it (anything ending in
Button, plus InkWell and the Cupertino set) and matches the name against
the text the button paints, which for Flutter lives in a descendant rather
than on the button itself. Both read the element tree, so neither needs an id.
Two questions that look the same and are not: exists asks whether a widget
is in the tree, isVisible asks whether the user can see it. A list keeps
items built for a while after they scroll away, so a driven tap on one refuses
rather than landing on whatever is drawn at those coordinates now.
Under integration_test, add one line to main() before your tests:
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.shouldPropagateDevicePointerEvents = true;That binding drops every pointer event that did not come from a
WidgetTester. Without the line the taps do nothing and say nothing; with
self_test you get an error that tells you this instead.
Wrapping is no longer required. It is still useful when a widget has no text, no key and no label to find it by, or when you want a recorded script to keep working after the copy changes.
import 'package:self_test/self_test.dart';
void main() {
runApp(SelfTestRoot(child: MyApp()));
}In a debug build this also draws the floating recording controls over your
app. They are hidden automatically under flutter_test, because they are a
live overlay and pumpAndSettle on a tree containing them never returns.
Use showControls to decide for yourself:
SelfTestRoot(showControls: false, child: MyApp()) // never draw them
SelfTestRoot(showControls: true, child: MyApp()) // always, tests included
SelfTestRoot(child: MyApp()) // debug app yes, test noSelfTestableWidget(
id: 'username_field',
onTextChange: (value) => setState(() => username = value),
child: TextField(
decoration: InputDecoration(labelText: 'Username'),
onChanged: (value) => setState(() => username = value),
),
),await SelfTestManager().enterText('username_field', 'john_doe');
await SelfTestManager().trigger('login_button');
await SelfTestManager().waitForAnimations();trigger and enterText still take an id and still work. They now
send a real pointer event when the widget is on screen, and fall back to the
registered callback only when it is not.
A typed controller removes the stringly-typed ids from your tests. It is
generated by self_test_gen, which is a dev dependency: nothing it pulls in
reaches your app.
dependencies:
self_test: ^0.2.0
dev_dependencies:
build_runner: ^2.4.9
self_test_gen: ^0.2.0Annotate the handlers you already wrote. The id is the same one the widget registers under:
// lib/login_form.dart
import 'package:flutter/material.dart';
import 'package:self_test/self_test.dart';
class _LoginFormState extends State<LoginForm> {
@SelfTestButton('login_btn')
void onLoginPressed() { /* ... */ }
@SelfTestInput('username_field')
void onUsernameChanged(String value) { /* ... */ }
@SelfTestInput('password_field')
void onPasswordChanged(String value) { /* ... */ }
}Generate:
dart run build_runner buildThat writes lib/login_form.self_test.g.dart, a standalone library. Do not
add a part directive for it, and do not import it from login_form.dart.
It is a library, not a part file, and importing a file that does not exist yet
makes login_form.dart unresolvable, which leaves the generator with no
annotations to find. Import it from your test:
// test/login_form_test.dart
import 'package:your_app/login_form.self_test.g.dart';
final controller = LoginFormStateTestController();
controller.enterUsernameField('john_doe');
controller.enterPasswordField('secret123');
controller.tapLoginBtn();
controller.expectUsernameFieldText('john_doe');
controller.expectLoginBtnExists();The extension is .self_test.g.dart rather than plain .g.dart because
source_gen already owns the latter: every part-based generator
(json_serializable, freezed, mobx, hive, drift) writes through
source_gen:combining_builder, which claims .dart -> .g.dart. Two builders
claiming one output makes build_runner refuse to start in that package at
all, taking your existing codegen down with it.
Naming rules, so you can predict the generated API without reading the output:
| From | Generated |
|---|---|
class _LoginFormState |
LoginFormStateTestController (a leading _ is dropped so the controller is usable from a test) |
@SelfTestButton('login_btn') |
tapLoginBtn(), expectLoginBtnExists(), expectLoginBtnDoesNotExist() |
@SelfTestInput('username_field') |
enterUsernameField(String), expectUsernameFieldText(String), plus the two existence assertions |
Ids are converted to camelCase for method names, so generated code passes the same lints as the rest of your project. The id itself is used verbatim in the calls, because that is the key the widget registered under.
A working end-to-end example, annotations through to a passing test, lives in
example/: see lib/example.dart for the annotations and
test/controller_test.dart for the generated controller in use.
Define multi-step test scenarios:
final scenario = TestScenario(
name: 'Login flow',
steps: [
TestStep.enterText('username_field', 'user@example.com'),
TestStep.enterText('password_field', 'password123'),
TestStep.tap('login_button'),
TestStep.wait(Duration(milliseconds: 500)),
TestStep.screenshot('after_login'),
],
);
final result = await scenario.run();
print(result.allPassed ? 'All steps passed' : 'Failed at step ${result.failedAtStep}');Integrate with flutter_test:
testWidgets('Login flow test', (WidgetTester tester) async {
SelfTestManager().setTestMode(true);
await tester.pumpWidget(MyApp());
await tester.pumpAndSettle();
SelfTestManager().enterText('username_field', 'testuser');
SelfTestManager().trigger('login_button');
await tester.pump();
expect(find.text('Login successful!'), findsOneWidget);
});// In debug/profile builds
SelfTestManager().setSelfTestModeActive(true);
SelfTestManager().restartWidgetTree();
// In test environments
SelfTestManager().setTestMode(true);Singleton managing test nodes and actions.
| Method | Description |
|---|---|
trigger(id) |
Tap a button by ID |
enterText(id, text) |
Enter text in a field by ID |
waitForAnimations() |
Wait for UI updates |
restartWidgetTree() |
Force widget tree rebuild |
captureScreenshot([name]) |
Capture a screenshot |
registerTestNode(node) |
Register a test node |
unregisterTestNode(id) |
Unregister a test node |
setSelfTestModeActive(bool) |
Enable/disable in debug/profile |
setTestMode(bool) |
Enable/disable in test environments |
SelfTestableWidget({
required String id,
required Widget child,
VoidCallback? onTap,
ValueSetter<String>? onTextChange,
})@SelfTestButton(String id) // For tappable widgets
@SelfTestInput(String id) // For text input widgetsThe self_test ecosystem consists of three components:
┌─────────────────────────────────────────────────────────────────┐
│ AI Agent (Claude) │
└─────────────────────────────┬───────────────────────────────────┘
│ MCP Protocol
▼
┌─────────────────────────────────────────────────────────────────┐
│ self_test_mcp Server │
│ • 60+ Playwright-equivalent tools │
│ • Actions: tap, type, scroll, drag │
│ • Assertions: expect, visual regression │
│ • State inspection: Riverpod, Bloc, Provider │
│ • Network mocking & monitoring │
└─────────────────────────────┬───────────────────────────────────┘
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────┐
│ Flutter App + self_test_bridge │
│ • Receives commands from MCP │
│ • Executes via self_test callbacks │
│ • Works on iOS, Android, Web, Desktop │
└─────────────────────────────────────────────────────────────────┘
| Package | Description | When to Use |
|---|---|---|
| self_test (this package) | Core: locators, real pointer events, recording | Always - enables runtime testing in your Flutter app |
| self_test_bridge | WebSocket bridge connecting MCP to Flutter | When using AI-powered testing with Claude |
| self_test_mcp | MCP server with 60+ tools for AI agents | When using AI agents for automated testing |
The self_test MCP (Model Context Protocol) server enables AI agents like Claude to test your Flutter apps with Playwright feature parity.
| Platform | Bridge Required | How It Works |
|---|---|---|
| iOS | ✅ Yes | Bridge runs WebSocket server on device, MCP connects |
| Android | ✅ Yes | Bridge runs WebSocket server on device, MCP connects |
| Web (with bridge) | ✅ Yes | Bridge embedded in web app, MCP connects |
| Web (bridgeless) | ❌ No | MCP uses Playwright + Flutter semantics tree |
| Desktop | ✅ Yes | Bridge runs WebSocket server in app, MCP connects |
npx self-test-mcp --helpAdd it to ~/.claude/settings.json. The token is the one your app prints at
startup, or, better for anything automated, the one you passed to
SelfTestBridge(token:) yourself: the printed one only exists while you are
watching a flutter run console.
{
"mcpServers": {
"flutter-self-test": {
"command": "npx",
"args": ["self-test-mcp"],
"env": {
"FLUTTER_APP_HOST": "127.0.0.1",
"FLUTTER_APP_PORT": "9999",
"SELF_TEST_TOKEN": "<the token the app printed>"
}
}
}
}Add to pubspec.yaml:
dependencies:
self_test_bridge:
git:
url: https://github.com/loonix/self_test
path: packages/self_test_bridgeAdd to main.dart:
import 'package:flutter/foundation.dart';
import 'package:self_test_bridge/self_test_bridge.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Debug only. The bridge refuses to start in a release build anyway: it is
// a remote control for the app, and it listens on a socket.
if (kDebugMode) {
final bridge = SelfTestBridge(port: 9999);
await bridge.start();
// Loopback and a fresh token per run. Give this to the MCP server.
debugPrint(bridge.url);
}
runApp(SelfTestRoot(child: MyApp()));
}Open Claude Code and ask:
The agent's first call is flutter_describe_screen, which answers with every
widget on screen and a ready-made locator for each. It does not need your app
to have been prepared in any way.
Test the login flow in my Flutter app:
1. Enter username "test@example.com"
2. Enter password "password123"
3. Tap login button
4. Verify we navigated to the dashboard
Claude will use the MCP tools to interact with your app!
Test any Flutter web app without code changes using Playwright:
# Configure for web-external mode
export BRIDGE_MODE=web-external
export FLUTTER_APP_URL=https://your-app.com
export PLAYWRIGHT_HEADLESS=false
# Run MCP server
npm startPerfect for:
- Production web apps
- Third-party Flutter apps
- CI/CD smoke tests
- Quick exploratory testing
The MCP server provides 60+ tools with Playwright feature parity:
Locators & Queries:
flutter_snapshot- Get widget treeflutter_get_by_role- Find by semantic roleflutter_get_by_text- Find by text content
Actions:
flutter_tap,flutter_type,flutter_clear,flutter_scrollflutter_drag,flutter_hover,flutter_focusflutter_long_press,flutter_double_tap
Assertions:
flutter_expect- Assert widget state (toBeVisible, toHaveText, etc.)flutter_expect_screenshot- Visual regression testing
State Management:
flutter_get_state- Inspect Riverpod/Bloc/Provider stateflutter_dispatch_action- Dispatch events/actionsflutter_watch_state- Subscribe to state changes
Network:
flutter_mock_http- Mock API responsesflutter_block_http- Block requestsflutter_network_log- Monitor network traffic
Platform Mocking:
flutter_set_geolocation- Mock GPSflutter_set_permission- Mock permissionsflutter_mock_channel- Mock platform channels
[See full tool list in packages/self_test_mcp/README.md]
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests
- Run the test suite:
flutter test - Submit a pull request
Copyright (c) 2025-2026 Ari Silva, Daniel Carneiro. All rights reserved.
This software may be used and modified in your own products and services, but may not be sold or redistributed as a standalone product. See the LICENSE file for details.