Flutter Isar Testing — In-Memory Setup
Problem
flutter test runs headless and can't find libisar.dylib on macOS. Isar.open() requires a real directory path (not null). Widget tests with the app's root widget crash (SIGTERM) because the app initializes Isar + all providers.
Solution: 3-Part Setup
1. Symlink libisar.dylib to project root
Isar looks for the native library in the project root. isar_flutter_libs has it but flutter test doesn't link it automatically.
ln -sf "$HOME/.pub-cache/hosted/pub.dev/isar_flutter_libs-*/macos/libisar.dylib" \
"$PWD/libisar.dylib"
2. Test helper — openTestIsar()
Create test/helpers/test_helpers.dart:
import 'dart:io';
import 'package:isar/isar.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Import ALL schemas + the isarProvider
import 'package:my_app/data/models/vehicle.dart';
import 'package:my_app/data/models/maintenance_record.dart';
import 'package:my_app/data/models/service_task.dart';
import 'package:my_app/data/models/part_price.dart';
import 'package:my_app/data/models/invoice_image.dart';
import 'package:my_app/data/datasources/local/isar_provider.dart';
Future<Isar> openTestIsar() async {
final name = 'test_${DateTime.now().microsecondsSinceEpoch}';
final dir = await Directory.systemTemp.createTemp('isar_test_');
return Isar.open(
[VehicleSchema, MaintenanceRecordSchema, ServiceTaskSchema,
PartPriceSchema, InvoiceImageSchema],
directory: dir.path, // REQUIRED — Isar 3.1.0+1 doesn't accept null
name: name,
);
}
Future<List<Override>> createTestOverrides() async {
final isar = await openTestIsar();
return [isarProvider.overrideWithValue(isar)];
}
3. Test pattern
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'helpers/test_helpers.dart';
void main() {
late Isar isar;
setUp(() async {
isar = await openTestIsar();
});
tearDown(() async {
await isar.close(deleteFromDisk: true);
});
group('My Test', () {
test('CRUD works', () async {
final vehicle = createTestVehicle(name: 'Test Car');
await isar.writeTxn(() async {
await isar.vehicles.put(vehicle);
});
final stored = await isar.vehicles.get(vehicle.id);
expect(stored!.name, equals('Test Car'));
});
});
}
Pitfalls & Fixes
isActiveEqualTo not available for non-indexed bool
Isar only generates query methods for @Index() fields. For non-indexed booleans:
// ❌ Fails
final active = await isar.vehicles.where().isActiveEqualTo(true).findAll();
// ✅ Works
final all = await isar.vehicles.where().findAll();
final active = all.where((v) => v.isActive).toList();
SettingsNotifier can't be used directly
Notifier requires a Riverpod container to build. Don't instantiate it directly:
// ❌ Fails — LateInitializationError
final notifier = SettingsNotifier();
notifier.build();
notifier.toggleLocale();
// ✅ Test SettingsState directly
const state = SettingsState(locale: AppLocale.en);
expect(state.t('app_title'), equals('My App'));
Cost predictor outlier test needs enough normal data
With too few data points, the std dev is inflated and outliers aren't filtered:
// ❌ 4 normal + 1 outlier — std dev too high, outlier not filtered
final records = [100, 105, 110, 1000]; // mean=328, std=377, z(1000)=1.78 < 2.0
// ✅ 10 normal + 1 outlier — std dev stable, outlier filtered
final records = [100, 102, 104, 106, 108, 100, 102, 104, 106, 108, 10000];
Don't double-close Isar
If tearDown calls isar.close(), don't close manually in the test:
// ❌ IsarError: instance already closed
test('test', () async {
// ... test ...
await isar.close(deleteFromDisk: false); // conflicts with tearDown
});
// ✅ Let tearDown handle cleanup
test('test', () async {
// ... test ... (no manual close)
});
Widget tests with the app's root widget crash in headless runner
The app's root widget initState() calls initIsarDatabase() which needs real native libs.
Options:
- Use
createTestOverrides()+ProviderScopeto inject test Isar - Move heavy widget tests to
integration_test/for device-based execution
Widget tests hang: pumpAndSettle timed out with Isar under FakeAsync
pumpAndSettle waits until no frames are scheduled, but an infinite spinner (loading state) or real async Isar work never settles under the widget test's FakeAsync clock → the test times out.
- If the widget shows an infinite loading spinner by design, never
pumpAndSettlewhile it is visible — useawait tester.pump(const Duration(...))with explicit steps. - Isar is REAL async; its futures do not resolve as the fake clock advances. For widgets that touch Isar: override the repository/use-case (inject fakes/Riverpod overrides) OR test with real-async patterns.
- ⚠️ STOP-28 correction (2026-08-11, LL-053): the previous wording "test with real-async patterns (
tester.runAsync) pluspump()with fixed durations" was AMBIGUOUS and was read as "call pump inside runAsync" — that nesting hung Linux CI for 10 minutes (fake-clock primitives inside real-clock runAsync starve waiting for a frame the real clock never schedules). It propagated to 13 files (44 sites). The correct rule:runAsyncwraps the WAITING only — NEVERpumpWidget/pump/tapinside it. The safe shape issettleRealfromtest/helpers/pump.dart(runAsync wraps only the waiting, never a pump), andtest/structure/test_harness_policy_test.dartfails on any NEW nesting. When a test passes locally but hangs CI on Linux — suspect this pattern first, not the scheduler. until:must be DATA-DEPENDENT, never chrome (2026-08-11, paid-debt round). The AppBar title of the destination screen renders before its providers' Isar reads land. Waiting on the title returns early → the assertions fail AND teardown'sisar.close()can hang on in-flight queries. Correct conditions, in order of strength: (a) single-provider screen → the first data text inside the body ('وش هي بالضبط؟'on TaskRecord); (b) multi-provider screen →find.byType(CircularProgressIndicator).evaluate().isEmpty && <screen title>; (c) full-app splash redirect → no spinner AND the seeded hero data ('Toyota Camry'), never the dashboard AppBar title.- Teardown hang class: trailing real async blocks
isar.close(). After a test that triggers a real write chain (TX + storage + provider invalidations), the app's tail may leave the Isar isolate busy;addTearDown(() => isar.close(...))then hangs forever (not caught by--timeout, which only covers the test body). Fix: before the test ends,await tester.settleReal(cycles: 40, step: const Duration(milliseconds: 10));— real event-loop turns deliver the pending response, the fake-frame pump flushes its continuation. A drain query alone (runAsync(findFirst)) does NOT fix it; unmounting the tree does NOT fix it; the settleReal alternation does. - Route/sheet exit animations need fake-clock frames. After
Navigator.pop(sheet close, dialog close, route fallback), assert the destination by waitingsettleReal(until: <the thing gone or the next screen's data>)— a single barepump()advances the fake clock by zero and the old screen is still in the tree. - Diagnose before patching: probe whether the async layer resolves under FakeAsync at all (a quick
tester.runAsyncexperiment) — throwing a different pump strategy blindly just moves the timeout.
Integration Tests (Device-Based)
For tests that need real Isar + full app lifecycle:
integration_test/
driver.dart
tc01_test.dart
app_full_flow_test.dart
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// ... tests run on device with real Isar
}
Run: flutter test integration_test/ (requires connected device)
Project File Structure
project/
libisar.dylib ← symlink to isar_flutter_libs
test/
helpers/
test_helpers.dart ← openTestIsar() + factory helpers
TC001_test.dart ← pure logic (no app root widget)
TC002_test.dart ← Isar CRUD
...
integration_test/
driver.dart ← integration test driver
app_full_flow_test.dart ← full app on device