Rejet non géré de Future dans les tests asynchrones Flutter
Problème
Les tests qui créent des Futures qui vont rejeter (lever des erreurs) peuvent échouer en CI même quand :
- Vous capturez l'erreur avec
.catchError()à la fin - Vous utilisez
try/catchautour de l'await - Le test passe en local
Le framework de test Flutter/Dart détecte les rejets de Future « non gérés » lors de l'exécution du test, même si vous prévoyez de les gérer plus tard. Cela cause des tests instables qui passent en local mais échouent en CI à cause des différences de timing.
Contexte / Conditions déclencheurs
Symptômes :
- Le test passe en local avec
flutter testmais échoue en CI - Le message d'erreur est tronqué ou cryptique (ex : « ROR] » au lieu de « [ERROR] »)
- Le nom du test apparaît dans la sortie d'erreur mais pas d'assertion claire en échec
- Le test implique la création de Futures vers des URLs/ressources qui n'existent pas
- Utilisation de patterns comme :
final future = someAsyncOperation(); // Cela va lever une erreur // ... faire des assertions ... await future.catchError((_) {}); // Trop tard - déjà marqué comme non géré
Scénarios courants :
- Tester qu'une méthode ne peut être appelée qu'une fois (gardes d'état)
- Tester le comportement de timeout/annulation
- Tester les chemins de gestion d'erreur
- N'importe quel test qui déclenche intentionnellement des erreurs dans du code async
Solution
Ne créez pas de Futures qui vont rejeter - testez la machine d'état directement
Au lieu de :
test('start throws if already started', () async {
final session = SomeSession(url: 'wss://fake.url');
// MAUVAIS : Cette Future va rejeter quand la connexion échoue
final startFuture = session.start();
// Même ceci ne va pas aider - rejet déjà détecté
await Future.delayed(Duration.zero);
expect(() => session.start(), throwsA(isA<StateError>()));
// Trop tard pour capturer - le test a déjà échoué
await startFuture.catchError((_) {});
});
Faites ceci :
test('start throws if already started', () {
// BON : Complètement synchrone, pas d'appels réseau
final session = SomeSession(url: 'wss://example.com');
// Utilisez une transition d'état synchrone pour sortir de l'état « startable »
session.cancel(); // Transition d'état sans appel réseau
// Maintenant testez que start() lève une erreur quand pas en état initial
expect(
() => session.start(),
throwsA(isA<StateError>()),
);
session.dispose();
});
Approches alternatives si vous devez utiliser async
Option 1 : Enveloppez la création de Future dans une zone qui ignore les erreurs
test('handles async error', () async {
late Future<void> errorFuture;
await runZonedGuarded(() async {
errorFuture = operationThatWillFail();
// Faire des assertions synchrones ici
}, (error, stack) {
// Ignorer les erreurs attendues
});
});
Option 2 : Utilisez expectLater pour les Futures qui doivent échouer
test('operation fails with specific error', () async {
// Laissez le framework de test savoir que cette Future DOIT échouer
await expectLater(
operationThatWillFail(),
throwsA(isA<SomeError>()),
);
});
Option 3 : Mockez la dépendance async
test('start throws if already started', () async {
final mockRelay = MockRelay();
when(mockRelay.connect()).thenAnswer((_) async => {}); // Ne lève jamais d'erreur
final session = SomeSession(relay: mockRelay);
await session.start();
expect(() => session.start(), throwsA(isA<StateError>()));
});
Vérification
- Le test passe en local :
flutter test path/to/test.dart - Le test passe en CI (vérifier GitHub Actions / autre CI)
- Le test est déterministe - lancer 10x avec le flag
--repeat=10
Exemple
Avant (instable) :
test('NostrConnectSession start throws if already started', () async {
final session = NostrConnectSession(relays: ['wss://relay.example.com']);
// Cela crée une Future qui va rejeter quand la connexion au relay échoue
final startFuture = session.start();
// L'état change de manière synchrone, mais le rejet de Future est en attente
expect(session.state, isNot(equals(NostrConnectState.idle)));
expect(() => session.start(), throwsA(isA<StateError>()));
session.cancel();
session.dispose();
// Cela n'aide pas - le rejet est déjà marqué par le framework de test
await startFuture.catchError((_) {});
});
Après (fiable) :
test('NostrConnectSession start throws if already started', () {
// Complètement synchrone - pas de Futures qui peuvent rejeter
final session = NostrConnectSession(relays: ['wss://relay.example.com']);
expect(session.state, equals(NostrConnectState.idle));
// Utilisez cancel() pour passer hors de l'état idle de manière synchrone
session.cancel();
expect(session.state, equals(NostrConnectState.cancelled));
// Maintenant start() lève une erreur parce qu'on n'est pas en état idle
expect(
() => session.start(),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('already started'),
),
),
);
session.dispose();
});
Notes
- Ce problème est plus courant en CI à cause des différentes caractéristiques de timing
- Les messages d'erreur tronqués (comme « ROR] ») se produisent parce que la sortie CI est coupée
- Les tests locaux peuvent passer parce que le garbage collector n'a pas encore tourné
- Ceci est différent de l'erreur « A Timer is still pending » (voir la skill
flutter-dispose-timer-test-failure) - Lors des tests de machines d'état, préférez tester les transitions d'état plutôt que le comportement async
- Si vous devez tester le comportement async/réseau réel, utilisez un mocking approprié
Skills connexes
flutter-dispose-timer-test-failure: Pour les défaillances de test liées aux timersriverpod-ref-in-provider-lifecycle: Pour les problèmes de rappel async dans les providers Riverpod