Skip to content

Testing

Terminal window
# Flutter and Dart tests
flutter test
# A specific test file
flutter test test/unit/server/disk_test.dart
# Generate a coverage report
flutter test --coverage

Dart tests live in test/ and are grouped into unit/, widget/, platform/, and migration/. Shared helpers and release fixtures live in helpers/ and fixtures/. Tests should verify behavior and input/output without depending on an external network or a real server.

unit/ is divided again by feature domain: ssh/, terminal/, file/, server/, store/, ai/, geo/, rootfs/, monitor/, benchmark/, remote_desktop/, and app/. A test belongs to the feature it covers rather than to the layer it reaches — a store, a provider, and a model of the same feature sit together. store/ holds storage infrastructure that belongs to no one feature (the database, the schema, backup and restore, settings), and app/ holds the rest: version, localization, crash reporting, diagnostics, and build checks.

Terminal window
# All tests in the Rust workspace
cargo test --workspace
# FFI parity test: build the FFI crate first
cargo build -p sbm_ffi
flutter test test/unit/app/frb_parser_test.dart

crates/sbm_parser/tests/dart_compat.rs uses the same fixtures as the Dart tests to lock parser behavior on both sides.

The following suites need a real host. They are skipped silently when the required environment variables are not set:

Terminal window
# SSH end to end: upload the generated script, run it remotely,
# and compare the parsed result with direct command output.
# Set this in the workspace-root .env:
# SBM_E2E_SSH_HOST=<SSH destination or ~/.ssh/config alias>
cargo test -p sbm_parser --test ssh_e2e
# Monitor terminal against a real sshd
# Requires SBM_E2E_TERMINAL_* environment variables.
cargo test -p server_box_monitor --test terminal_ws

The Monitor Svelte frontend has an independent Vitest and Testing Library suite:

Terminal window
cd monitor/frontend
npm run test
npm run test:coverage
npm run check

npm run check performs type checking and is also part of npm run build.

Use unit tests for pure business logic, models, and parsers:

test('calculates CPU percentage', () {
final cpu = CpuModel(usage: 75.0);
expect(cpu.usagePercentage, '75%');
});

Use Widget tests for layout, text, and interaction:

testWidgets('shows the server name', (tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: ServerCard(server: testServer),
),
),
);
expect(find.text('Test Server'), findsOneWidget);
});

A widget test that writes to a store must call openTestDb() in setUp and closeTestDb() in tearDown. The latter drains pending writes before closing SQLite. Construct the normal store against the isolated database; migration tests use the production setting store name. Do not add production reset methods, test-only constructors, or mutable network factories. Stateful services use their normal instance lifecycle; HTTP and filesystem substitutes belong in test/helpers/.

Do not use pumpAndSettle() on a tree containing a text field or another Widget that continually schedules frames. Count frames explicitly with pump(duration) instead, and use a reasonable test timeout.

Use Provider tests to verify state and asynchronous results:

test('returns server status', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final status = await container.read(serverStatusProvider(testServer).future);
expect(status, isA<StatusModel>());
});

The default test suite must remain deterministic. Parser, model, command-builder, and ordinary Widget tests must not access a network or a real server. When a feature introduces a service boundary, add a targeted fake or fixture.

The SSH end-to-end and real-sshd suites above are exceptions. They run only when their environment variables are configured, so a default cargo test --workspace still needs no external service.

A storage migration usually gets one chance to process a user’s data. Once it writes its completion marker, the old data is not read again. A migration bug is therefore more likely to silently lose data than to crash.

Every migration must keep a permanent regression test using bytes written by the release being migrated from, not data regenerated by the current adapter:

File Purpose
test/migration/hive_release_migration_test.dart Runs Hive import and the registered migrations against each release fixture
test/fixtures/hive_v{1466,1480,1491}/ Boxes written by those releases, plus their generators and documentation
test/migration/hive_import_test.dart Verifies import retry, idempotency, and per-box progress
test/migration/m0NN_*_test.dart One per schema migration, verifying that step’s behavior

A fixture generated with the current adapter only proves that the current code agrees with itself. It cannot prove that the current decoder still reads the format written by an old release. Once a fixture is used for regression coverage, never regenerate it to make a failing test pass.

  1. Check out the target release with git worktree add /tmp/<tag> <tag>, initialize the submodules required by its pubspec.yaml, and run flutter pub get.
  2. Copy test/fixtures/hive_v1466/gen_fixture.dart.txt into a temporary test. Adapt it to that release’s models, adapters, and dependency versions, then generate .hive bytes with the old release’s own code. Cover every optional field, every enum value, every store type, and include non-ASCII text, quotes, and newlines.
  3. Copy the generated files into test/fixtures/hive_v<tag>/, keep the generator notes and README, then remove the worktree.
  4. Write the current-version reading test through the public store API, and check that the database encoding contains no fields from the old shape.

The generator is checked in as .txt because it targets an old release’s API and is not expected to pass analyze in the current tree.

integration_test/ covers questions that flutter test cannot answer. Unit tests run under flutter_tester, which does not load plugins. Code reached through a plugin or FFI therefore does not execute in the real App environment there. Integration tests run on a connected device or simulator:

File What it verifies
local_shell_test.dart Whether a shell on the device can actually start
rootfs_shell_test.dart Whether the Alpine userland works through the App’s API
android_exec_test.dart What Android permits the App to execute from its own directory
android_rootfs_test.dart Whether the Android guest mechanism works
ios_rootfs_test.dart The Linux userland on iOS
ios_bench_test.dart Linux guest overhead on real hardware
ios_load_test.dart App overhead while the guest is running
sandbox_import_test.dart Taking over data from the App Store sandbox build
Terminal window
# Requires a connected device or simulator
flutter test integration_test/local_shell_test.dart

make analyze also analyzes integration_test/.

When Xcode connects to an iOS 17+ device over the network, publish the driver port:

Terminal window
flutter drive --publish-port \
--driver=integration_test/driver.dart \
--target=integration_test/ios_rootfs_test.dart

The device may request local-network permission on the first run. Allow it for the test to connect.

  1. Organize tests with Arrange–Act–Assert.
  2. Name tests after the behavior they verify, not the implementation.
  3. Add enough assertions for important behavior while keeping each test focused.
  4. Isolate external dependencies with fakes or fixtures.
  5. Cover empty lists, missing values, invalid input, and permission failures.

Signed rootfs fixtures must retain their original bytes: JSON uses LF and signatures are binary in .gitattributes. Never re-sign or regenerate a fixture to make a test pass — a fixture is bytes a release actually wrote, and rewriting it only proves the current code agrees with itself.

Where a test can, it should use the production path rather than a substitute: fixtures go through the real installer, and HTTP tests use a local socket.