flutter-async-test-unhandled-future-rejection

Par divinevideo · divine-mobile

Corrigez les tests Flutter/Dart instables qui échouent en CI mais passent en local à cause de rejets de Future non gérés. À utiliser quand : (1) le test passe en local mais échoue en CI avec des erreurs cryptiques, (2) le test crée des Futures qui vont lever des erreurs (ex. : appels réseau vers de fausses URLs), (3) même avec `.catchError()` à la fin, le test échoue quand même, (4) le message d'erreur affiche le nom du test mais avec une erreur tronquée comme `"ROR]"` ou `"[ERROR]"`. Solution : évitez de créer des Futures qui vont rejeter ; testez plutôt le comportement de la machine d'état avec des opérations synchrones.

npx skills add https://github.com/divinevideo/divine-mobile --skill flutter-async-test-unhandled-future-rejection

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/catch autour 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 test mais é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

  1. Le test passe en local : flutter test path/to/test.dart
  2. Le test passe en CI (vérifier GitHub Actions / autre CI)
  3. 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 timers
  • riverpod-ref-in-provider-lifecycle : Pour les problèmes de rappel async dans les providers Riverpod

Références

Skills similaires