Recipes

Testing

Test Qora queries, mutations, and widgets with deterministic helpers: no network, no Hive, no timers.

Core principle: replace real I/O with deterministic fakes. Qora has no global state, and each QoraClient instance is isolated for deterministic test execution.


Setup

No extra packages are required for the core patterns. Add mocktail if you want to mock API classes:

pubspec.yaml
dev_dependencies:
  mocktail: ^0.3.0
  flutter_test:
    sdk: flutter

Unit Testing

For packages/qora tests (no Flutter), use QoraClient directly:

test/queries_test.dart
import 'package:qora/qora.dart';
import 'package:test/test.dart';

void main() {
  late QoraClient client;

  setUp(() {
    client = QoraClient(
      config: const QoraClientConfig(
        defaultOptions: QoraOptions(
          retryCount: 0, // disable retries for fail-fast tests
        ),
      ),
    );
  });

  tearDown(() => client.dispose());

  test('fetchQuery returns data on success', () async {
    final user = await client.fetchQuery<User>(
      key: const ['users', 1],
      fetcher: () async => User(id: 1, name: 'Alice'),
    );

    expect(user.name, 'Alice');
  });

  test('fetchQuery throws on fetcher error', () {
    expect(
      () => client.fetchQuery<User>(
        key: const ['users', 99],
        fetcher: () async => throw Exception('not found'),
        options: const QoraOptions(retryCount: 0),
      ),
      throwsException,
    );
  });

  test('second fetchQuery returns cached data without calling fetcher', () async {
    var callCount = 0;

    await client.fetchQuery<User>(
      key: const ['users', 1],
      fetcher: () async { callCount++; return User(id: 1, name: 'Alice'); },
      options: const QoraOptions(staleTime: Duration(minutes: 5)),
    );
    await client.fetchQuery<User>(
      key: const ['users', 1],
      fetcher: () async { callCount++; return User(id: 1, name: 'Alice'); },
      options: const QoraOptions(staleTime: Duration(minutes: 5)),
    );

    expect(callCount, 1); // cache hit on second call
  });
}

Widget Testing: QoraScope Wrapper

Wrap pumpWidget with QoraScope. Pass a custom client so each test is isolated:

test/user_screen_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:qora_flutter/qora_flutter.dart';
import 'package:qora/qora.dart';

Widget buildTestApp({
  required QoraClient client,
  required Widget child,
}) {
  return QoraScope(
    client: client,
    child: MaterialApp(home: child),
  );
}

void main() {
  testWidgets('UserScreen shows name after fetch', (tester) async {
    final client = QoraClient(
      config: const QoraClientConfig(
        defaultOptions: QoraOptions(retryCount: 0),
      ),
    );
    addTearDown(client.dispose);

    await tester.pumpWidget(
      buildTestApp(
        client: client,
        child: QoraBuilder<User>(
          queryKey: const ['users', 1],
          fetcher: () async => User(id: 1, name: 'Alice'),
          builder: (context, state, _) => switch (state) {
            Initial() || Loading() => const CircularProgressIndicator(),
            Success(:final data) => Text(data.name),
            Failure(:final error) => Text('Error: $error'),
          },
        ),
      ),
    );

    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    await tester.pumpAndSettle(); // let the future resolve

    expect(find.text('Alice'), findsOneWidget);
  });
}

Pre-Seeding the Cache

Use setQueryData to pre-populate the cache before mounting the widget. The QoraBuilder receives Success immediately: no async needed:

test/user_screen_test.dart
testWidgets('UserScreen renders pre-seeded data without fetching', (tester) async {
  final client = QoraClient();
  addTearDown(client.dispose);

  // Seed the cache directly: widget gets Success on first frame
  client.setQueryData<User>(const ['users', 1], User(id: 1, name: 'Bob'));

  await tester.pumpWidget(
    buildTestApp(
      client: client,
      child: QoraBuilder<User>(
        queryKey: const ['users', 1],
        fetcher: () async => throw Exception('should not be called'),
        options: const QoraOptions(staleTime: Duration(minutes: 5)),
        builder: (_, state, __) =>
            Text(state.dataOrNull?.name ?? 'loading'),
      ),
    ),
  );

  // No pump needed: data was already in cache
  expect(find.text('Bob'), findsOneWidget);
});

Testing Error States

Pass a fetcher that throws and check the Failure branch:

test/user_screen_test.dart
testWidgets('shows error message when fetch fails', (tester) async {
  final client = QoraClient(
    config: const QoraClientConfig(
      defaultOptions: QoraOptions(retryCount: 0), // fail immediately
    ),
  );
  addTearDown(client.dispose);

  await tester.pumpWidget(
    buildTestApp(
      client: client,
      child: QoraBuilder<User>(
        queryKey: const ['users', 404],
        fetcher: () async => throw Exception('not found'),
        builder: (_, state, __) => switch (state) {
          Failure(:final error) => Text('Error: $error'),
          _ => const CircularProgressIndicator(),
        },
      ),
    ),
  );

  await tester.pumpAndSettle();

  expect(find.textContaining('Error:'), findsOneWidget);
});

Testing PersistQoraClient

Use InMemoryStorageAdapter: it satisfies the StorageAdapter interface with no file I/O, making persistence tests deterministic and fast:

test/persistence_test.dart
import 'package:qora/qora.dart';
import 'package:test/test.dart';

void main() {
  test('data survives a simulated restart', () async {
    final storage = InMemoryStorageAdapter();
    await storage.init();

    // ── First "launch" ────────────────────────────────────────────────────
    final client1 = PersistQoraClient(
      storage: storage,
      persistDuration: const Duration(days: 7),
    );
    client1.registerSerializer<User>(
      QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson),
      name: 'User',
    );
    await client1.hydrate();

    await client1.fetchQuery<User>(
      key: const ['user'],
      fetcher: () async => User(id: 1, name: 'Alice'),
    );
    client1.dispose();

    // ── Second "launch": same storage ────────────────────────────────────
    final client2 = PersistQoraClient(
      storage: storage,
      persistDuration: const Duration(days: 7),
    );
    client2.registerSerializer<User>(
      QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson),
      name: 'User',
    );
    await client2.hydrate();

    // Data is in cache without any network call
    final state = client2.getQueryState<User>(const ['user']);
    expect(state, isA<Success<User>>());
    expect((state as Success<User>).data.name, 'Alice');

    client2.dispose();
  });
}

Testing Optimistic Updates

Verify that rollback restores the exact pre-mutation state:

test/optimistic_test.dart
test('rollback restores previous state on failure', () async {
  final client = QoraClient();
  addTearDown(client.dispose);

  // Seed initial data
  client.setQueryData<User>(const ['user'], User(id: 1, name: 'Alice'));

  final snapshot = client.getQueryData<User>(const ['user']);

  // Apply optimistic update
  client.setQueryData<User>(const ['user'], User(id: 1, name: 'Bob'));
  expect(client.getQueryState<User>(const ['user']), isA<Success<User>>());
  expect(
    (client.getQueryState<User>(const ['user']) as Success<User>).data.name,
    'Bob',
  );

  // Simulate failure: rollback
  client.restoreQueryData<User>(const ['user'], snapshot);

  expect(
    (client.getQueryState<User>(const ['user']) as Success<User>).data.name,
    'Alice',
  );
});

Quick Reference

What to testHow
Fetcher is called once (cache deduplication)Count calls in a closure
Data is cached across two callsstaleTime > 0, call twice, check callCount == 1
Error state reaches the widgetFetcher throws, retryCount: 0, pumpAndSettle
Widget shows pre-seeded datasetQueryData before pumpWidget
Persistence survives restartTwo PersistQoraClient instances sharing an InMemoryStorageAdapter
Optimistic rollbackgetQueryData → setQueryData → restoreQueryData → check state
refetchOnWindowFocusDon't wire FlutterLifecycleManager in tests: avoids unwanted side effects

Copyright © 2026