self_test 2.3.1
self_test: ^2.3.1 copied to clipboard
Automated regression testing for Flutter through direct callback invocation. Run tests in live app environments without UI simulation.
example/lib/main.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:self_test/self_test.dart';
import 'package:self_test_bridge/self_test_bridge.dart';
import 'example.dart';
/// Where the bridge listens, and what it demands before answering anything.
///
/// Overridable with `--dart-define` so two copies of the app, or an app and a
/// simulator forwarding the same port, do not fight over 9999.
const port = int.fromEnvironment('SELF_TEST_PORT', defaultValue: 9999);
const token = String.fromEnvironment(
'SELF_TEST_TOKEN',
defaultValue: 'probe-token',
);
final navigatorKey = GlobalKey<NavigatorState>();
/// How the bridge pops a route, and how it reads the whole route stack.
///
/// Without it every `goBack` answers `No BridgeNavigator is configured` and
/// the suite cannot walk back to the first screen between scenarios, so the
/// second scenario starts wherever the first one left the app.
final bridgeNavigator = NavigatorStateBridgeNavigator(navigatorKey);
/// The bridge this demo serves, built where a test can look at it.
///
/// Inline in [main] it could not be: starting it opens a socket, so the only
/// way to find out that the navigator had been left off was to drive the
/// running app and read the refusal.
SelfTestBridge buildDemoBridge() =>
SelfTestBridge(navigator: bridgeNavigator, port: port, token: token);
/// This example is also the demo host: it runs the app *and* serves the report
/// page and the socket the `self_test:suite` command drives. Every line below
/// is here because leaving it out breaks something that still reports success,
/// which is why the README's quickstart is this same wiring.
Future<void> main() async {
// self_test's own binding, and first: a plugin that takes the default
// messenger before this runs cannot be mocked by a stored step afterwards.
SelfTestWidgetsFlutterBinding.ensureInitialized();
SelfTestBridge? bridge;
// Not `kDebugMode`: a profile build is the one a phone runs when a debug
// build is too slow to be worth driving, and it needs the bridge just as
// much. Release builds ship without it.
if (!kReleaseMode) {
// A demo keeps its scripts and reports in the system temp directory. A
// real app uses `path_provider`'s application support directory instead,
// so they survive a reboot: see the README.
final home = Directory('${Directory.systemTemp.path}/self_test_example')
..createSync(recursive: true);
final manager = SelfTestManager();
// Without a store the recordings live in memory and die with the process,
// so a script recorded by hand is gone before it can be run again.
manager.useRecordingStore(FileRecordingStore('${home.path}/scripts'));
await manager.initializeRecordingStore();
// Without this a run leaves no record: no steps, no screenshots, and an
// empty report page.
manager.useRunReportStore(FileRunReportStore('${home.path}/runs'));
bridge = buildDemoBridge();
await bridge.start();
debugPrint('self_test: http://127.0.0.1:$port/report?token=$token');
}
runApp(
SelfTestRoot(
navigatorKey: navigatorKey,
child: MyApp(bridge: bridge),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key, this.bridge});
/// Null in the widget tests, which pump this app without a server.
final SelfTestBridge? bridge;
@override
Widget build(BuildContext context) {
final bridge = this.bridge;
return MaterialApp(
title: 'Self Test Example',
// The same key the bridge was given: a command that pops a route needs
// the navigator, and without the observer the bridge cannot tell which
// screen is in front.
navigatorKey: navigatorKey,
navigatorObservers: [
bridgeNavigator.observer,
if (bridge != null) bridge.navigationObserver,
],
theme: ThemeData(primarySwatch: Colors.blue),
home: const LoginPage(),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
String username = '';
String password = '';
String message = '';
late TextEditingController _usernameController;
late TextEditingController _passwordController;
@override
void initState() {
super.initState();
_usernameController = TextEditingController(text: username);
_passwordController = TextEditingController(text: password);
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _onLoginPressed() {
if (username.isNotEmpty && password.isNotEmpty) {
setState(() {
message = 'Login successful!';
});
} else {
setState(() {
message = 'Please fill all fields';
});
}
}
void _onUsernameChanged(String value) {
setState(() {
username = value;
});
_usernameController.text = value;
}
void _onPasswordChanged(String value) {
setState(() {
password = value;
});
_passwordController.text = value;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
SelfTestableWidget(
id: 'username_field',
onTextChange: _onUsernameChanged,
child: TextField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
onChanged: _onUsernameChanged,
),
),
const SizedBox(height: 16),
SelfTestableWidget(
id: 'password_field',
onTextChange: _onPasswordChanged,
child: TextField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
onChanged: _onPasswordChanged,
),
),
const SizedBox(height: 16),
SelfTestableWidget(
id: 'login_button',
onTap: _onLoginPressed,
child: ElevatedButton(
onPressed: _onLoginPressed,
child: const Text('Login'),
),
),
const SizedBox(height: 16),
Text(message),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
debugPrint('[SelfTest] Activating self-test mode...');
SelfTestManager().setSelfTestModeActive(true);
// No snackbar here. restartWidgetTree rebuilds everything
// below SelfTestRoot, MaterialApp and its ScaffoldMessenger
// included, so a snackbar shown around this call is torn down
// in the same frame. The restart is what makes the widgets
// re-register, so it is the snackbar that has to go.
SelfTestManager().restartWidgetTree();
},
child: const Text('Activate Self-Test Mode'),
),
// Survives the restart, unlike a snackbar, because it is derived
// from the manager rather than held in this widget's state.
Text(
SelfTestManager().isSelfTestModeActive
? 'Self-test mode activated'
: 'Self-test mode off',
),
ElevatedButton(
onPressed: () async {
debugPrint('[SelfTest] ===== STARTING TEST =====');
SelfTestManager().enterText('username_field', 'testuser');
SelfTestManager().enterText('password_field', 'testpass');
await SelfTestManager().waitForAnimations();
SelfTestManager().trigger('login_button');
await SelfTestManager().waitForAnimations();
debugPrint('[SelfTest] ===== TEST COMPLETED =====');
},
child: const Text('Run Test'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ExampleWidget(),
),
);
},
child: const Text('Open Example Widget'),
),
],
),
),
);
}
}