self_test 2.1.0 copy "self_test: ^2.1.0" to clipboard
self_test: ^2.1.0 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
0
points
544
downloads

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

unknown (license)

Dependencies

flutter, meta

More

Packages that depend on self_test