Skip to content

Architecture

Testing ​

Archipelago provides a complete testing setup out of the box — unit tests, widget tests, golden tests for the UI kit, and integration tests with Patrol (default) or Maestro. The integrationTestFramework option (patrol, maestro or none) and includeIntegrationTest control which integration test files are generated.

Test Structure ​

apps/your_app/
  test/
    flutter_test_config.dart          # Global unit/widget test config
    architecture/                     # Feature boundary test
    di/                               # Composition-root and DI guard tests
    src/
      ...                             # Unit & widget tests
  integration_test/
    flutter_test_config.dart          # Global integration test config
    integration_test_driver.dart      # Driver for vanilla flutter test
    patrol_config.dart                # Patrol test configuration
    app_test.dart                     # Smoke test — verifies app launches
  pubspec.yaml                        # `patrol:` section: Patrol CLI settings (flavor, ids)
  android/app/src/androidTest/...     # MainActivityTest, Patrol's Android entry point
  ios/RunnerUITests/                  # Patrol's iOS UI testing target
  .maestro/                           # Only with integrationTestFramework: maestro (config.yaml + flows/)

With integrationTestFramework: maestro the integration_test/ folder and Patrol's native setup (androidTest, RunnerUITests) are removed and .maestro/ is kept instead. With none (or includeIntegrationTest: false) none of them are generated.

Unit & Widget Tests ​

Every package has its own test/ directory. Run all tests across the workspace:

bash
melos run test

Run tests for selected packages (melos prompts you to pick them):

bash
melos run test:select

INFO

melos run test runs every package, including *_ui_kit, but skips tests tagged golden (flutter test --exclude-tags golden). Ordinary UI kit tests run; golden tests do not. Run goldens with melos run test:golden (see below).

flutter_test_config.dart ​

The test/flutter_test_config.dart file is automatically loaded before any test runs. Use it for global setup:

dart
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  TestWidgetsFlutterBinding.ensureInitialized();
  // Offline: the theme loads Google Fonts, and a runtime fetch hangs
  // pumpAndSettle.
  GoogleFonts.config.allowRuntimeFetching = false;
  await testMain();
}

The generated app and every generated package whose widget tests load the app theme ship this file, with google_fonts as a dev dependency. Without it, a test that renders themed text fetches the font over the network: it passes online and times out in a sandbox or CI without network. A package generated before this existed picks it up with archipelago upgrade <brick>. The app has no generation record, so add the file and the google_fonts dev dependency by hand, and do the same for a package you wrote yourself.

Test the Composition Root ​

The composition root is initializeInjector(): the one call that bootstrap() makes to build the whole DI container before runApp(). test/di/injector_test.dart runs it under plain flutter test. The test catches DI graph defects on the day they land, for example a type registered twice or an eager module that resolves a type nobody registered.

Several packages do platform I/O while the container builds. For example, app_debugger reads path_provider, and package_info_provider calls PackageInfo.fromPlatform(). Under flutter test no platform channel answers these calls, so the test needs fakes. You do not write the fakes yourself. Each package that does the I/O ships a TestPrerequisite and declares it in its own pubspec.yaml (see test_prereq). monorepo_toolkit generate test-harness collects the declarations of the installed packages into test/support/composition_root_harness.dart:

dart
import '../support/composition_root_harness.dart';

void main() {
  setUpAll(() async {
    await installCompositionRootPrerequisites();
    await initializeInjector();
  });

  tearDownAll(getIt.reset);

  test('a lazySingleton (Log) resolves to one instance', () {
    expect(identical(getIt<Log>(), getIt<Log>()), isTrue);
  });
}

Rules:

  • Commit the harness. It is not build_runner output, so nothing regenerates it on checkout.
  • Bricks keep it current. Every generated app ships the test and the harness. When you install a brick, its hook adds the brick's prerequisite to the harness, the same way it adds the DI module to injector.dart.
  • Regenerate it after you add or remove a prerequisite package by hand: dart run monorepo_toolkit generate test-harness. melos run di:check runs test-harness --check, so CI fails on drift.
  • Never add a fake to the test file. If the test fails with a MissingPluginException, the package that calls the plugin is missing its archipelago: test_prerequisites entry. Add the entry there, then regenerate.
  • Never set getIt.allowReassignment = true. A duplicate registration must fail this test.

A green run proves that the graph builds. It does not prove that the graph is correct: lazy registrations that the test does not resolve are never built. Add project-specific assertions below the generated ones.

DI Guard Tests ​

The DI rules of feature manifests, lazy SDKs and per-feature containers fail silently when someone breaks them. An eager SDK shows no symptom, and a second port instance shows up only when you add caching. So every generated app and feature ships tests that guard them. None of them lists features by hand.

App tests (apps/your_app/test/):

TestFails when
architecture/feature_boundary_test.dartA feature's pubspec.yaml depends on another feature's implementation package (not its _api). A single-package feature (the only package in its features/<name>/ directory, not named _impl or _noop, such as webview_advanced) has no separate implementation, so depending on it is allowed.
di/lazy_feature_sdk_test.dartA feature in FeatureSDKRegistry.enabledManifests is an eager FeatureSDK that is not in the _eagerByDesign allowlist, or initializeInjector() builds a *SDKImpl that is not allowlisted (for example a registerLazySingleton changed to registerSingleton).
di/base_scope_budget_test.dartThe set of instances that initializeInjector() builds differs from test/di/base_scope_budget.txt.
di/port_singleton_test.dartA port registered with // di-global: is a factory, or a feature's *_imports.dart builds a type instead of forwarding root<T>().

Feature test (features/<name>/<name>_impl/test/di/<name>_di_test.dart): the global module registers the manifest and a lazy SDK; the container cannot resolve a root type it did not import; and the container builds from a fake root that holds only the imported types, resolves every registration, and is empty after the last lease is released. A closable singleton (a Cubit, a Bloc or a StreamController) that the teardown did not close fails the test: register it with dispose:.

Rules:

  • Eager allowlist. _eagerByDesign names the FeatureSDKs that must exist before the first frame: auth, push notifications, connectivity, shorebird, webview_advanced, home_widget, app_blackbox and the debug tooling. Add a name only with the reason. A feature that can wait gets a FeatureManifest and a lazy port instead.
  • Boot budget. GetIt cannot list registrations that are not built yet, so the budget records the instances built at boot, by class name. A lazy registration costs nothing at boot and is not in it. A feature global module that registers an extra root type is reported by the feature_global_module_allowlist lint instead. A new app has no budget file: the first local run writes it, and you commit it. In CI (CI is set), a missing file fails. To accept a change on purpose, run flutter test --update-goldens test/di/base_scope_budget_test.dart and commit the new file. A lazy feature does not change the budget, so archipelago generate feature does not need to refresh it.
  • Feature fakes. In the feature test, register one fake in the fake root for each type that <name>_imports.dart forwards. A missing fake fails the test, which is how a missing import shows up.

Integration Tests with Patrol ​

Patrol provides native-aware integration testing for Flutter. It can interact with system dialogs, permissions, and native UI elements.

Install Patrol CLI ​

bash
dart pub global activate patrol_cli

Run Integration Tests ​

bash
# Run with melos
melos run integration-test

# Run all integration tests
melos run integration-test:all

# Run directly with Patrol
cd apps/your_app
patrol test --flavor development --target integration_test/app_test.dart

With more than one device attached, melos run integration-test cannot pick one; run the same command with --device <id> (for example --device emulator-5554).

Writing Tests ​

The generated smoke test starts the real app through bootstrap:

dart
patrolTest('app launches without crash', config: patrolAppConfig, ($) async {
  // bootstrap installs the app's own error handlers; give the test framework
  // its handler back, or a failure never reaches Patrol and the run hangs.
  final onError = FlutterError.onError;
  addTearDown(() => FlutterError.onError = onError);

  // bootstrap calls runApp itself and builds the app after pre-launch, so
  // wait for the app widget instead of pumping a second tree.
  await bootstrap(
    flavor: 'development',
    builder: (result) => AcmeApp(router: result.requireRouter),
  );
  final app = find.byType(AcmeApp);
  for (var i = 0; i < 100 && app.evaluate().isEmpty; i++) {
    await $.pump(const Duration(milliseconds: 100));
  }

  expect(app, findsOneWidget);
  FlutterError.onError = onError;

  // Native dialogs (for example the notification permission prompt):
  // await $.native.grantPermissionWhenInUse();
});

Do not await bootstrap(...) and then pumpWidget the app it built: bootstrap returns before the builder runs.

Configuration ​

pubspec.yaml, patrol: section. patrol_cli 4.x reads its settings here. The older patrol.toml is not read any more and is no longer generated:

yaml
patrol:
  app_name: "Your App [Dev]"
  flavor: development
  test_directory: integration_test
  android:
    package_name: "com.example.your_app.dev"
  ios:
    bundle_id: "com.example.yourApp.dev"

The ids are the flavor's real ids (flavorizr.yaml), because Patrol uses them to uninstall the app after each test. To test another flavor, pass --flavor staging and use that flavor's ids.

Native setup. The bricks generate everything Patrol's native setup asks for:

PlatformWhat is generated
AndroidtestInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner", clearPackageData, the Android Test Orchestrator in android/app/build.gradle.kts, and android/app/src/androidTest/java/<package>/MainActivityTest.java
iOSA RunnerUITests UI testing target linked to FlutterGeneratedPluginSwiftPackage (Swift 5.0 on every configuration), ios/RunnerUITests/RunnerUITests.m, target 'RunnerUITests' with inherit! :complete inside target 'Runner' in the Podfile, and the target in each scheme's Test action (a post-gen step; it runs after flavorizr, so the flavored schemes and build configurations match)

The Podfile entry matters for flavored apps: flavorizr's project-level configurations include the CocoaPods xcconfig, so without it the UI-test bundle links the CocoaPods plugins but never embeds them, and fails to load ("Failed to load the test bundle"). A project generated before this change: add the block by hand, set SWIFT_VERSION = 5.0 on the RunnerUITests configurations, and run pod install.

app_scaffold adds RunnerUITests before the app has flavors; dart run monorepo_toolkit flavor generate --app <app> then copies the flavored build configurations onto it (flutter_flavorizr itself only adds them to Runner). If a project's RunnerUITests has only Debug/Release/Profile, run flavor generate --app <app> --force again; otherwise patrol test --flavor fails with "No target application path specified".

Patrol builds iOS tests for a generic simulator, so every Swift package compiles for x86_64 as well. MoEngage's SDK has no x86_64 simulator slice, so with the MoEngage vendor the integration-test scripts set XCODE_XCCONFIG_FILE to ios/Flutter/patrol_simulator.xcconfig, which excludes it.

On iOS, if the project is not yet configured for Swift Package Manager, run a first flutter build ios --config-only, then open the project once and check that RunnerUITests lists FlutterGeneratedPluginSwiftPackage under Frameworks and Libraries.

patrol_config.dart — Shared test config:

dart
const patrolTestConfig = PatrolTestConfig(
  findTimeout: Duration(seconds: 10),
);

Running Without Patrol ​

For CI environments without Patrol, use the standard Flutter integration test driver:

bash
flutter test integration_test/app_test.dart

Golden Tests (Visual Regression) ​

Golden tests validate your design system tokens visually. Archipelago uses Alchemist for golden testing — it renders widgets into images and compares them against baseline snapshots.

What's Tested ​

The UI kit includes golden tests for all design tokens:

FoundationWhat it validates
ColorsBackground, foreground, action, outline colors in light & dark themes
TypographyDisplay, heading, body, label, code styles in both themes
Spacing12-step spacing scale (none → xl6) as sized bars
RadiusAll 8 radius tokens applied to containers
ShadowsShadow tokens comparison
ButtonsAll styles, sizes, states, icons across themes
CardsVariants, sizes, interactive states
Text FieldsStates, sizes, icons, variants

Sample Golden Output ​

Foundations ​

Color Tokens:

Color Tokens

Semantic Colors (Light vs Dark):

Semantic Colors

Typography:

Typography

Individual Typography Categories

Display

Display

Heading

Heading

Body

Body

Label

Label

Button

Button

Code

Code

Spacing Scale:

Spacing

Radius Scale:

Radius

Shadows:

Shadows

Components ​

Button Styles:

Buttons

More Button Goldens

Filled Style

Filled

Outlined Style

Outlined

Text Style

Text

Sizes

Sizes

States

States

With Icons

Icons

Expanded

Expanded

Card Overview:

Cards

More Card Goldens

Variants

Variants

Sizes

Sizes

Interactive

Interactive

Text Fields:

TextField Overview

More TextField Goldens

Variants

Variants

Sizes

Sizes

States

States

Icons

Icons

Password Fields:

Password Overview

More Password Field Goldens

Variants

Variants

States

States

Visibility Toggle

Toggle

Strength Indicator

Strength

Running Golden Tests ​

Verify against the committed baselines (read-only, runs on all *_ui_kit packages):

bash
melos run test:golden

Generate or update baseline images (first time, or after intentional changes) from inside the UI kit package:

bash
cd packages/your_app_ui_kit
flutter test --tags golden --update-goldens

Golden tests are opt-in and excluded from melos run test by the golden tag, not by package name. A UI kit package can mix golden and ordinary tests, and only the golden ones are skipped. GoldenTestHelper adds the tag to every golden it creates. Tag your own goldenTest(...) calls with tags: ['golden']; an untagged golden runs in the default gate.

If a package has only golden tests, flutter test --exclude-tags golden finds nothing to run and exits with code 79. The test:select and coverage:run:parallel scripts treat 79 as success. Any other exit code still fails the run.

The reason for the exclusion: pixel fixtures depend on the Skia engine, fonts and OS they were captured on. Regenerate them on the machine or CI image you gate on. affected golden (Enterprise) runs the same suite on affected packages only.

Writing a Golden Test ​

dart
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';

import '../../util/app_test_wrapper.dart';

void main() {
  goldenTest(
    'button renders correctly',
    fileName: 'components/button_variants',
    builder: () => GoldenTestGroup(
      scenarioConstraints: BoxConstraints(maxWidth: 300),
      children: [
        GoldenTestScenario(
          name: 'primary filled',
          child: buildTestWrapper(
            child: AppButton(
              label: 'Submit',
              variant: ButtonVariant.primary,
            ),
          ),
        ),
        GoldenTestScenario(
          name: 'primary outlined',
          child: buildTestWrapper(
            child: AppButton(
              label: 'Cancel',
              variant: ButtonVariant.primary,
              type: ButtonType.outlined,
            ),
          ),
        ),
      ],
    ),
    tags: ['golden'],
  );
}

Test Wrapper ​

Use buildTestWrapper() to wrap widgets with the correct theme:

dart
Widget buildTestWrapper({
  required Widget child,
  bool isDark = false,
}) {
  return MaterialApp(
    theme: isDark ? AppTheme.dark() : AppTheme.light(),
    home: Scaffold(body: child),
  );
}

CI Considerations ​

Alchemist renders text as colored blocks in CI mode to avoid cross-platform font differences. This means golden files generated on macOS will also pass on Linux CI runners.

Golden File Structure ​

packages/your_app_ui_kit/
  dart_test.yaml                    # Declares the golden tags
  test/
    flutter_test_config.dart
    golden_test_helper.dart
    fonts/                          # Bundled fonts so goldens render the same everywhere
    helpers/
      golden_test_helpers.dart
    foundations/
      colors/                       # Foreground and outline color tests
      themes/                       # Color, contrast, radius, shadow, spacing, typography goldens
    components/
      atoms/inputs/                 # Component tests (e.g. pin input)
    util/
      app_component_test_wrapper.dart

Coverage ​

Run tests with coverage and check thresholds:

bash
melos run coverage:full

This runs the full pipeline: collect → combine → check thresholds.

See Test Coverage for detailed configuration.

Main App vs Widgetbook ​

Main AppWidgetbook
PurposeProduction applicationUI component catalog & preview
PlatformsAndroid, iOSAndroid, iOS, web, macOS, Linux, Windows
Has integration testsYes (Patrol)No
Has unit testsYesMinimal (snapshot tests)
Has CMakeNo (mobile only)Yes (desktop support for previewing)
Entry pointmain_<flavor>.dartmain.dart
DIFull GetIt setupMinimal (just UI components)

Why Widgetbook Has Desktop Support ​

The widgetbook app is initialized with Android, iOS, web, macOS, Linux and Windows platform folders so designers and developers can preview components on desktop without needing a mobile emulator. This is purely for development convenience — the production app remains mobile-only.

CMake Files ​

Desktop CMake files are located at:

apps/your_app_widgetbook/
  linux/
    CMakeLists.txt              # Linux build config
    flutter/CMakeLists.txt      # Flutter plugin loader
  windows/
    CMakeLists.txt              # Windows build config
    flutter/CMakeLists.txt      # Flutter plugin loader

These follow the standard Flutter desktop CMake structure and are created by flutter create during generation.