self_test 2.3.1 copy "self_test: ^2.3.1" to clipboard
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'),
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
544
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Automated regression testing for Flutter through direct callback invocation. Run tests in live app environments without UI simulation.

Repository (GitHub)
View/report issues

Topics

#testing #flutter #regression #automation

License

MIT (license)

Dependencies

flutter, meta

More

Packages that depend on self_test