Guides Pratiques

Tests

Tester les requêtes, mutations et widgets Qora avec des helpers déterministes : pas de réseau, pas de Hive, pas de timers.

Principe fondamental : remplacer les I/O réelles par des fakes déterministes. Qora n'a pas d'état global, et chaque instance de QoraClient est isolée pour une exécution de test déterministe.


Configuration

Aucun package supplémentaire n'est requis pour les patterns principaux. Ajouter mocktail pour mocker les classes API :

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

Tests unitaires

Pour les tests de packages/qora (sans Flutter), utiliser QoraClient directement :

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, // désactiver les retries pour les tests fail-fast
        ),
      ),
    );
  });

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

  test('fetchQuery retourne les données en cas de succès', () async {
    final user = await client.fetchQuery<User>(
      key: const ['users', 1],
      fetcher: () async => User(id: 1, name: 'Alice'),
    );

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

  test('fetchQuery lève une exception sur erreur du fetcher', () {
    expect(
      () => client.fetchQuery<User>(
        key: const ['users', 99],
        fetcher: () async => throw Exception('not found'),
        options: const QoraOptions(retryCount: 0),
      ),
      throwsException,
    );
  });

  test('le second fetchQuery retourne les données en cache sans appeler le 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 au second appel
  });
}

Tests de widgets : wrapper QoraScope

Encapsuler pumpWidget avec QoraScope. Passer un client personnalisé pour que chaque test soit isolé :

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 affiche le nom après le 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('Erreur : $error'),
          },
        ),
      ),
    );

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

    await tester.pumpAndSettle(); // laisser le future se résoudre

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

Pré-alimentation du cache

Utiliser setQueryData pour pré-peupler le cache avant de monter le widget. Le QoraBuilder reçoit Success immédiatement : pas d'async nécessaire :

test/user_screen_test.dart
testWidgets('UserScreen affiche les données pré-alimentées sans fetcher', (tester) async {
  final client = QoraClient();
  addTearDown(client.dispose);

  // Alimenter le cache directement : le widget reçoit Success à la première 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('ne devrait pas être appelé'),
        options: const QoraOptions(staleTime: Duration(minutes: 5)),
        builder: (_, state, __) =>
            Text(state.dataOrNull?.name ?? 'chargement'),
      ),
    ),
  );

  // Pas de pump nécessaire : les données étaient déjà en cache
  expect(find.text('Bob'), findsOneWidget);
});

Tester les états d'erreur

Passer un fetcher qui lève une exception et vérifier la branche Failure :

test/user_screen_test.dart
testWidgets('affiche le message d\'erreur quand le fetch échoue', (tester) async {
  final client = QoraClient(
    config: const QoraClientConfig(
      defaultOptions: QoraOptions(retryCount: 0), // échec immédiat
    ),
  );
  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('Erreur : $error'),
          _ => const CircularProgressIndicator(),
        },
      ),
    ),
  );

  await tester.pumpAndSettle();

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

Tester PersistQoraClient

Utiliser InMemoryStorageAdapter : il satisfait l'interface StorageAdapter sans I/O fichier, rendant les tests de persistance déterministes et rapides :

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

void main() {
  test('les données survivent à un redémarrage simulé', () async {
    final storage = InMemoryStorageAdapter();
    await storage.init();

    // ── Premier "lancement" ────────────────────────────────────────────────
    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 "lancement" : même stockage ────────────────────────────────
    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();

    // Les données sont en cache sans aucun appel réseau
    final state = client2.getQueryState<User>(const ['user']);
    expect(state, isA<Success<User>>());
    expect((state as Success<User>).data.name, 'Alice');

    client2.dispose();
  });
}

Tester les mises à jour optimistes

Vérifier que le rollback restaure l'état exact pré-mutation :

test/optimistic_test.dart
test('le rollback restaure l\'état précédent en cas d\'échec', () async {
  final client = QoraClient();
  addTearDown(client.dispose);

  // Alimenter les données initiales
  client.setQueryData<User>(const ['user'], User(id: 1, name: 'Alice'));

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

  // Appliquer la mise à jour optimiste
  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',
  );

  // Simuler un échec : rollback
  client.restoreQueryData<User>(const ['user'], snapshot);

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

Référence rapide

Que testerComment
Le fetcher est appelé une seule fois (déduplication cache)Compter les appels dans une closure
Les données sont en cache entre deux appelsstaleTime > 0, appeler deux fois, vérifier callCount == 1
L'état d'erreur atteint le widgetLe fetcher lève, retryCount: 0, pumpAndSettle
Le widget affiche les données pré-alimentéessetQueryData avant pumpWidget
La persistance survit au redémarrageDeux instances PersistQoraClient partageant un InMemoryStorageAdapter
Rollback optimistegetQueryData → setQueryData → restoreQueryData → vérifier l'état
refetchOnWindowFocusNe pas câbler FlutterLifecycleManager dans les tests : évite les effets secondaires indésirables

Voir aussi

Copyright © 2026