Tests
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 :
dev_dependencies:
mocktail: ^0.3.0
flutter_test:
sdk: flutter
Tests unitaires
Pour les tests de packages/qora (sans Flutter), utiliser QoraClient directement :
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é :
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 :
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 :
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 :
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('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 tester | Comment |
|---|---|
| Le fetcher est appelé une seule fois (déduplication cache) | Compter les appels dans une closure |
| Les données sont en cache entre deux appels | staleTime > 0, appeler deux fois, vérifier callCount == 1 |
| L'état d'erreur atteint le widget | Le fetcher lève, retryCount: 0, pumpAndSettle |
| Le widget affiche les données pré-alimentées | setQueryData avant pumpWidget |
| La persistance survit au redémarrage | Deux instances PersistQoraClient partageant un InMemoryStorageAdapter |
| Rollback optimiste | getQueryData → setQueryData → restoreQueryData → vérifier l'état |
refetchOnWindowFocus | Ne pas câbler FlutterLifecycleManager dans les tests : évite les effets secondaires indésirables |
Voir aussi
- Guide de persistance :
PersistQoraClient,InMemoryStorageAdapter, sérialiseurs - Guide des mises à jour optimistes :
setQueryData,getQueryData,restoreQueryData - API QoraClient : référence complète des méthodes