Automated regression testing for Flutter through direct callback invocation. Unlike traditional testing frameworks that simulate UI interactions, self_test invokes widget callbacks directly, providing fast and reliable testing for development and pre-production environments.
🤖 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!
- Direct Callback Invocation - Execute user actions by calling widget callbacks directly instead of injecting touch events
- 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.
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),
),
),SelfTestManager().enterText('username_field', 'john_doe');
SelfTestManager().trigger('login_button');
await SelfTestManager().waitForAnimations();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.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.g.dart';
final controller = LoginFormStateTestController();
controller.enterUsernameField('john_doe');
controller.enterPasswordField('secret123');
controller.tapLoginBtn();
controller.expectUsernameFieldText('john_doe');
controller.expectLoginBtnExists();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 testing framework with direct callback invocation | 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 |
cd packages/self_test_mcp
npm install && npm run build
# Add to Claude Code automatically
./scripts/setup-claude.shOr manually add to ~/.claude/settings.json:
{
"mcpServers": {
"flutter-self-test": {
"command": "node",
"args": ["/path/to/self_test_mcp/dist/index.js"],
"env": {
"FLUTTER_APP_HOST": "localhost",
"FLUTTER_APP_PORT": "9999"
}
}
}
}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();
// Start bridge in debug mode only
if (kDebugMode) {
final bridge = SelfTestBridge(port: 9999);
await bridge.start();
}
runApp(SelfTestRoot(child: MyApp()));
}Open Claude Code and ask:
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.