# Flutter Isar Testing

> In-memory Isar testing setup for Flutter — unit tests with real Isar DB on macOS

- Skill: `jahfaliabdulrahman-dev/flutter-isar-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jahfaliabdulrahman-dev/flutter-isar-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jahfaliabdulrahman-dev/flutter-isar-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jahfaliabdulrahman-dev (https://skillmd.com/u/jahfaliabdulrahman-dev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jahfaliabdulrahman-dev/flutter-isar-testing

---


# 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.

```bash
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`:

```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

```dart
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:
```dart
// ❌ 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:
```dart
// ❌ 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:
```dart
// ❌ 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:
```dart
// ❌ 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:
1. **Use `createTestOverrides()` + `ProviderScope`** to inject test Isar
2. **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 `pumpAndSettle` while it is visible — use `await 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`) plus `pump()` 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: `runAsync` wraps the WAITING only — NEVER `pumpWidget`/`pump`/`tap` inside it.** The safe shape is `settleReal` from `test/helpers/pump.dart` (runAsync wraps only the waiting, never a pump), and `test/structure/test_harness_policy_test.dart` fails 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's `isar.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 waiting `settleReal(until: <the thing gone or the next screen's data>)` — a single bare `pump()` 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.runAsync` experiment) — 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
```

```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
```

