Chapitre 12 · Semaine 3

Tests

Tests unitaires, de widgets, golden et d'intégration, appliqués à une app Riverpod 3 avec Dio et go_router. Dans les grands comptes, la question n'est pas « testez-vous ? » mais « comment, à quel niveau, avec quelle couverture et quelle porte qualité en CI ? ». Ce chapitre vous prépare à y répondre avec du code écrit sans aide.

Durée 3 séances de 2 h Niveau intermédiaire à avancé Prérequis chapitres 05, 06, 08 et 09

Objectifs

  • Expliquer la pyramide des tests mobile et ce que chaque niveau prouve.
  • Tester sans IA un AsyncNotifier Riverpod 3 et ses transitions loading / data / error.
  • Écrire des tests de widgets fiables : choisir entre pump et pumpAndSettle, surcharger les providers, tester la navigation.
  • Mettre en place goldens, test d'intégration, couverture et leur place dans la CI.

La pyramide des tests appliquée au mobile

▲ intégration : quelques parcours critiques, sur appareil ▲▲▲ golden : composants visuels clés, plateforme figée ▲▲▲▲▲▲▲ widget : écrans et composants, providers surchargés ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ unitaires : logique, notifiers, repositories, mappers

Plus on monte, plus un test est lent, fragile et coûteux à diagnostiquer, mais plus il prouve que l'ensemble fonctionne. Flutter a une particularité : le test de widget tourne sans appareil, en quelques centaines de millisecondes, avec un vrai moteur de layout. C'est le niveau le plus rentable pour l'UI, bien plus que dans le natif où le test d'UI implique presque toujours un émulateur.

NiveauCe qu'il prouveOutilVitesse
UnitaireUne fonction, une classe, un notifier se comporte comme prévupackage:test (réexporté par flutter_test)millisecondes
WidgetUn écran affiche le bon état et réagit aux interactionsflutter_test, WidgetTestercentaines de ms
GoldenLe rendu visuel n'a pas changé au pixel prèsmatchesGoldenFilecentaines de ms
IntégrationL'app réelle fonctionne sur un appareil, plugins natifs et réseau comprisintegration_test, Patrolminutes

Ce que les grands comptes attendent

  • Une CI qui bloque : chaque merge request lance flutter analyze et flutter test ; un test rouge empêche le merge.
  • Un seuil de couverture, souvent entre 70 et 80 % sur les couches domaine et données, mesuré hors code généré. Le chiffre importe moins que ce qu'il couvre : 80 % de getters testés ne protègent rien.
  • Une porte qualité (quality gate) de type SonarQube : couverture du nouveau code, duplication, bugs et vulnérabilités détectés, dette. SonarQube analyse Dart dans ses versions récentes et importe le rapport lcov.
  • Des tests lisibles par l'équipe : noms qui décrivent le comportement métier, une seule raison d'échouer par test, pas de dépendance à l'ordre d'exécution.

Tests unitaires

Un test unitaire n'a besoin ni d'appareil ni du framework UI. On organise avec group, on prépare avec setUp (avant chaque test) ou setUpAll (une fois), on nettoie avec tearDown ou addTearDown. Les matchers rendent les assertions expressives et les messages d'échec utiles.

import 'package:flutter_test/flutter_test.dart';
import 'package:wallet/core/errors/failure.dart';
import 'package:wallet/core/utils/money.dart';

void main() {
  group('formatMinor', () {
    test('formate des centimes en dirhams avec deux décimales', () {
      expect(formatMinor(125050), '1250,50 MAD');
    });

    test('refuse un montant négatif', () {
      expect(() => formatMinor(-1), throwsA(isA<ArgumentError>()));
    });
  });

  group('matchers utiles', () {
    test('isA + having décrit précisément un objet', () {
      final failure = parseError({'error': {'code': 'INSUFFICIENT_FUNDS', 'message': 'Solde insuffisant'}});
      expect(
        failure,
        isA<InsufficientFunds>().having((f) => f.message, 'message', 'Solde insuffisant'),
      );
    });

    test('Future et Stream', () async {
      // completion : le Future se termine avec cette valeur.
      await expectLater(Future.value(42), completion(42));
      // emitsInOrder : séquence d'événements, puis fin du flux.
      await expectLater(Stream.fromIterable([1, 2]), emitsInOrder([1, 2, emitsDone]));
    });
  });
}

Tester du code qui dépend du temps

Un test qui attend réellement 300 ms est lent ; un test qui dépend de l'heure courante est instable (minuit, changement d'heure). Deux outils. fake_async exécute le code dans une zone où Timer, Future.delayed et les microtâches sont contrôlés : elapse fait avancer un temps virtuel instantanément. Pour DateTime.now(), on injecte une horloge : une fonction DateTime Function(), ou clock.now() du package clock, que fakeAsync fait avancer avec le temps virtuel.

import 'dart:async';

import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';

class Debouncer {
  Debouncer(this.delay);

  final Duration delay;
  Timer? _timer;

  void run(void Function() action) {
    _timer?.cancel();
    _timer = Timer(delay, action);
  }

  void dispose() => _timer?.cancel();
}

void main() {
  test("n'exécute que la dernière action, 300 ms après la dernière frappe", () {
    fakeAsync((async) {
      final calls = <String>[];
      final debouncer = Debouncer(const Duration(milliseconds: 300));

      debouncer.run(() => calls.add('sa'));
      async.elapse(const Duration(milliseconds: 200));
      debouncer.run(() => calls.add('sal'));

      async.elapse(const Duration(milliseconds: 299));
      expect(calls, isEmpty);

      async.elapse(const Duration(milliseconds: 1));
      expect(calls, ['sal']);
    });
  });
}

Dans un test de widget, vous êtes déjà dans une zone de temps simulé : tester.pump(const Duration(milliseconds: 300)) fait avancer l'horloge. N'utilisez jamais Future.delayed réel dans un testWidgets : il ne se termine pas tant que le temps virtuel n'avance pas.

Mocks avec mocktail, ou fakes écrits à la main

Un mock est un objet dont on programme les réponses et dont on vérifie les appels. Un fake est une implémentation simplifiée mais fonctionnelle (un repository en mémoire). mocktail crée des mocks sans génération de code, contrairement à mockito.

import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockTransferRepository extends Mock implements TransferRepository {}

// Nécessaire pour utiliser any() avec un type non primitif.
class FakeTransferRequest extends Fake implements TransferRequest {}

void main() {
  late MockTransferRepository repo;

  setUpAll(() => registerFallbackValue(FakeTransferRequest()));
  setUp(() => repo = MockTransferRepository());

  test('envoie le montant demandé', () async {
    when(() => repo.transfer(any()))
        .thenAnswer((_) async => fakeTransaction(id: 'tx_1'));

    await repo.transfer(
      const TransferRequest(beneficiaryId: 'b1', amount: 15000, idempotencyKey: 'k1'),
    );

    verify(
      () => repo.transfer(
        any(that: isA<TransferRequest>().having((r) => r.amount, 'amount', 15000)),
      ),
    ).called(1);
    verifyNoMoreInteractions(repo);
  });

  test('une erreur asynchrone se programme avec thenAnswer', () {
    // Future.error plutôt que thenThrow : la méthode réelle échoue de façon
    // asynchrone, pas par une exception synchrone à l'appel.
    when(() => repo.transfer(any()))
        .thenAnswer((_) => Future.error(const InsufficientFunds()));
    expect(
      repo.transfer(const TransferRequest(beneficiaryId: 'b1', amount: 1, idempotencyKey: 'k')),
      throwsA(isA<InsufficientFunds>()),
    );
  });
}
class FakeTransferRepository implements TransferRepository {
  final requests = <TransferRequest>[];
  Object? nextError; // erreur à renvoyer au prochain appel

  @override
  Future<Transaction> transfer(TransferRequest request) async {
    requests.add(request);
    final error = nextError;
    if (error != null) throw error;
    return fakeTransaction(id: 'tx_${requests.length}', amount: request.amount);
  }
}

Pourquoi préférer souvent le fake : il se lit comme du code normal, il est réutilisable dans tous les tests, il ne casse pas quand on renomme un paramètre interne, et il permet de vérifier un état (repo.requests) plutôt qu'une suite d'appels. Les mocks restent utiles pour une interface large dont on n'utilise qu'une méthode, ou pour vérifier qu'un appel n'a pas eu lieu (verifyNever).

À dire en entretien

« Je mocke aux frontières : réseau, stockage, horloge, plateformes. Je ne mocke pas mes propres notifiers ni mes modèles. Pour les repositories, je préfère souvent un fake en mémoire : les tests vérifient un état observable plutôt que des appels, et ils survivent aux refactorings. J'utilise mocktail quand je dois vérifier qu'un appel a eu lieu, ou n'a pas eu lieu. »

Tester des providers Riverpod 3

Riverpod a été conçu pour être testable : un provider ne dépend que d'autres providers, et chacun se surcharge (override). ProviderContainer.test() crée un conteneur isolé et le libère automatiquement à la fin du test. Un conteneur par test, jamais partagé.

Voici le TransferNotifier du projet fil rouge (introduit au chapitre 06), sous une forme simplifiée :

final transferRepositoryProvider = Provider<TransferRepository>(
  (ref) => throw UnimplementedError('fourni au démarrage ou surchargé en test'),
);

/// Générateur d'Idempotency-Key, surchargeable pour des tests déterministes.
final idGeneratorProvider = Provider<String Function()>((ref) => generateUuidV4);

class TransferNotifier extends AsyncNotifier<Transaction?> {
  @override
  FutureOr<Transaction?> build() => null; // aucun transfert envoyé

  Future<void> submit({
    required String beneficiaryId,
    required int amount,
    String? note,
  }) async {
    if (state.isLoading) return; // double tap ignoré
    final request = TransferRequest(
      beneficiaryId: beneficiaryId,
      amount: amount,
      note: note,
      idempotencyKey: ref.read(idGeneratorProvider)(),
    );
    state = const AsyncLoading();
    final result = await AsyncValue.guard(
      () => ref.read(transferRepositoryProvider).transfer(request),
    );
    if (!ref.mounted) return; // provider libéré pendant l'appel
    state = result;
  }
}

final transferProvider =
    AsyncNotifierProvider<TransferNotifier, Transaction?>(TransferNotifier.new);
void main() {
  late FakeTransferRepository repo;
  late ProviderContainer container;

  setUp(() {
    repo = FakeTransferRepository();
    container = ProviderContainer.test(
      overrides: [
        transferRepositoryProvider.overrideWithValue(repo),
        idGeneratorProvider.overrideWithValue(() => 'key-1'),
      ],
      // Riverpod 3 relance automatiquement un provider dont build échoue :
      // en test, on veut voir l'erreur tout de suite.
      retry: (_, _) => null,
    );
  });

  test('submit passe par loading puis data', () async {
    final states = <AsyncValue<Transaction?>>[];
    // listen garde le provider vivant et enregistre chaque transition.
    container.listen(transferProvider, (_, next) => states.add(next), fireImmediately: true);

    await container.read(transferProvider.notifier).submit(beneficiaryId: 'b1', amount: 15000);

    expect(states, [
      isA<AsyncData<Transaction?>>().having((s) => s.value, 'value', isNull),
      isA<AsyncLoading<Transaction?>>(),
      isA<AsyncData<Transaction?>>().having((s) => s.value?.id, 'id', 'tx_1'),
    ]);
    expect(repo.requests.single.idempotencyKey, 'key-1');
  });

  test('une erreur métier donne AsyncError avec le Failure typé', () async {
    repo.nextError = const InsufficientFunds();
    final sub = container.listen(transferProvider, (_, _) {});

    await container.read(transferProvider.notifier).submit(beneficiaryId: 'b1', amount: 999999);

    expect(
      sub.read(),
      isA<AsyncError<Transaction?>>().having((s) => s.error, 'error', isA<InsufficientFunds>()),
    );
  });

  test('un double tap n\'envoie qu\'une requête', () async {
    container.listen(transferProvider, (_, _) {});
    final notifier = container.read(transferProvider.notifier);

    await Future.wait([
      notifier.submit(beneficiaryId: 'b1', amount: 100),
      notifier.submit(beneficiaryId: 'b1', amount: 100),
    ]);

    expect(repo.requests, hasLength(1));
  });
}
Piège d'entretien

« Comment mockez-vous un provider ? » La mauvaise réponse : « je mocke le notifier avec mocktail ». La documentation Riverpod le déconseille ; on surcharge plutôt la dépendance du notifier (le repository) avec overrideWithValue ou overrideWith, et on teste le vrai notifier. Si l'on doit vraiment remplacer un notifier, le faux doit étendre la classe de base, pas l'implémenter. Second piège : lire un provider autoDispose avec container.read sans listener, l'état peut être libéré entre deux lignes ; d'où container.listen.

Tests de widgets

testWidgets fournit un WidgetTester. pumpWidget monte un widget, puis on interagit (tap, enterText, drag) et on fait avancer les frames.

MéthodeEffetPiège
pump()Une frame : traite les setState et changements en attenteN'avance pas une animation jusqu'au bout
pump(duration)Avance le temps simulé puis une frameIl faut connaître la durée à simuler
pumpAndSettle()Pompe jusqu'à ce qu'aucune frame ne soit programméeÉchoue par timeout sur une animation infinie (CircularProgressIndicator, shimmer) ; masque le temps réellement écoulé

Les finders localisent les widgets : find.text, find.byKey (le plus stable : des Key('login.submit') posées volontairement), find.byType, find.widgetWithText, find.bySemanticsLabel, find.descendant. Les matchers associés : findsOneWidget, findsNothing, findsNWidgets(n), findsAtLeast(n).

class TransferStatus extends ConsumerWidget {
  const TransferStatus({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return switch (ref.watch(transferProvider)) {
      AsyncLoading() => const CircularProgressIndicator(),
      AsyncError(error: InsufficientFunds()) => const Text('Solde insuffisant'),
      AsyncError() => const Text('Le transfert a échoué'),
      AsyncData(value: final tx?) => Text('Transfert ${tx.id} effectué'),
      AsyncData() => const SizedBox.shrink(),
    };
  }
}
void main() {
  late FakeTransferRepository repo;

  Future<void> pumpStatus(WidgetTester tester) {
    return tester.pumpWidget(
      ProviderScope(
        overrides: [
          transferRepositoryProvider.overrideWithValue(repo),
          idGeneratorProvider.overrideWithValue(() => 'k'),
        ],
        retry: (_, _) => null,
        child: const MaterialApp(home: Scaffold(body: TransferStatus())),
      ),
    );
  }

  setUp(() => repo = FakeTransferRepository());

  testWidgets('affiche le message métier si le solde est insuffisant', (tester) async {
    repo.nextError = const InsufficientFunds();
    await pumpStatus(tester);

    await tester.container().read(transferProvider.notifier).submit(beneficiaryId: 'b1', amount: 1);
    await tester.pump(); // une frame suffit : pas d'animation à attendre

    expect(find.text('Solde insuffisant'), findsOneWidget);
    expect(find.byType(CircularProgressIndicator), findsNothing);
  });

  testWidgets('affiche la transaction créée', (tester) async {
    await pumpStatus(tester);

    await tester.container().read(transferProvider.notifier).submit(beneficiaryId: 'b1', amount: 1);
    await tester.pump();

    expect(find.text('Transfert tx_1 effectué'), findsOneWidget);
  });
}
Piège d'entretien

« Mon test reste bloqué sur pumpAndSettle puis échoue par timeout. » Un indicateur de chargement tourne indéfiniment : il programme toujours une nouvelle frame, donc l'arbre n'est jamais « stable ». Solutions : pump() ou pump(duration) explicites, ou faire en sorte que le fake réponde pour que l'indicateur disparaisse. Autre piège : un vrai client HTTP dans un test de widget. Par défaut, flutter_test fait échouer les requêtes réseau réelles (code 400) : il faut surcharger le repository ou l'adapter Dio.

Tester la navigation

On réutilise le vrai routerProvider du chapitre 05 et on surcharge l'état d'auth avec un notifier factice qui étend le vrai :

class FakeAuthNotifier extends AuthNotifier {
  FakeAuthNotifier(this.initial);

  final AuthState initial;

  @override
  AuthState build() => initial;

  void emit(AuthState next) => state = next;
}

testWidgets('un deep link protégé passe par le login puis revient au détail', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        authProvider.overrideWith(() => FakeAuthNotifier(const Unauthenticated())),
      ],
      child: const WalletApp(),
    ),
  );
  final container = tester.container();
  final router = container.read(routerProvider);

  router.go('/transactions/tx_42');
  await tester.pumpAndSettle();
  expect(router.state.uri.path, '/login');

  (container.read(authProvider.notifier) as FakeAuthNotifier).emit(const Authenticated('u1'));
  await tester.pumpAndSettle();
  expect(router.state.uri.path, '/transactions/tx_42');
});

Golden tests

Un golden test compare le rendu d'un widget à une image de référence (le golden file) versionnée dans le dépôt. Il détecte les régressions visuelles qu'aucune assertion sur le texte ne verrait : un padding perdu, une couleur de thème cassée, un débordement en arabe.

@Tags(['golden'])
library;

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('TransactionTile : débit en attente', (tester) async {
    // Taille et densité figées : le rendu ne dépend pas de la machine.
    tester.view.physicalSize = const Size(800, 200);
    tester.view.devicePixelRatio = 2;
    addTearDown(tester.view.reset);

    await tester.pumpWidget(
      MaterialApp(
        theme: buildTheme(Brightness.light),
        home: Scaffold(
          body: TransactionTile(
            transaction: fakeTransaction(id: 'tx_1', amount: 15000, status: 'pending'),
          ),
        ),
      ),
    );

    await expectLater(
      find.byType(TransactionTile),
      matchesGoldenFile('goldens/transaction_tile_pending.png'),
    );
  });
}
# Générer ou mettre à jour les images de référence après un changement voulu
flutter test --update-goldens --tags golden
# En CI : comparer uniquement
flutter test --tags golden

Sensibilités à connaître. Polices : par défaut, les tests utilisent une police de test qui dessine des rectangles à la place des glyphes ; c'est stable mais peu parlant. Charger les vraies polices (FontLoader) rend les goldens lisibles mais plus sensibles. Plateforme : l'anticrénelage et le rendu du texte diffèrent légèrement entre macOS, Linux et Windows ; un golden généré sur le Mac d'un développeur échoue sur le runner Linux de la CI. On génère et on vérifie les goldens sur une seule plateforme (souvent le Linux de la CI, ou un conteneur Docker en local), et on les isole par un tag. alchemist structure ces pratiques : scénarios multiples sur une même image, goldens « CI » à polices neutres et goldens « plateforme » lisibles.

Tests d'intégration

integration_test (fourni par le SDK) exécute des tests écrits avec la même API que les tests de widgets, mais dans l'app réelle, sur un émulateur ou un appareil : plugins natifs, stockage sécurisé, vrai réseau. On les réserve aux parcours critiques : connexion, transfert, déconnexion.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:wallet/app/app.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('connexion avec le compte de démo puis affichage du solde', (tester) async {
    await tester.pumpWidget(const ProviderScope(child: WalletApp()));
    await pumpUntilFound(tester, find.byKey(const Key('login.phone')));

    await tester.enterText(find.byKey(const Key('login.phone')), '0600000001');
    await tester.enterText(find.byKey(const Key('login.password')), 'demo1234');
    await tester.tap(find.byKey(const Key('login.submit')));

    // Vrai réseau : on attend l'élément plutôt qu'un pumpAndSettle aveugle.
    await pumpUntilFound(tester, find.byKey(const Key('home.balance')));
    expect(find.textContaining('MAD'), findsWidgets);
  });
}

Future<void> pumpUntilFound(
  WidgetTester tester,
  Finder finder, {
  Duration timeout = const Duration(seconds: 15),
}) async {
  final end = DateTime.now().add(timeout);
  while (DateTime.now().isBefore(end)) {
    await tester.pump(const Duration(milliseconds: 100));
    if (finder.evaluate().isNotEmpty) return;
  }
  throw TestFailure('Introuvable après $timeout : $finder');
}
# Backend local lancé, émulateur Android démarré
flutter test integration_test/login_test.dart \
  --dart-define=API_BASE_URL=http://10.0.2.2:8080/api/v1

integration_test ne peut pas toucher l'UI native : dialogue de permission, notification, biométrie système, WebView d'authentification. Patrol étend integration_test pour interagir avec ces éléments natifs et améliore la syntaxe des finders. Pour des parcours qui traversent des écrans natifs, c'est l'outil de référence de l'écosystème.

Tester Dio et ses intercepteurs

Dio délègue l'envoi réel à un HttpClientAdapter. En remplaçant l'adapter par un fake, on teste toute la chaîne Dio (intercepteurs, sérialisation, gestion d'erreurs) sans réseau. C'est plus fidèle que de mocker l'objet Dio, qui court-circuiterait justement les intercepteurs qu'on veut tester.

import 'dart:typed_data';

import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';

class FakeAdapter implements HttpClientAdapter {
  FakeAdapter(this._handler);

  final Future<ResponseBody> Function(RequestOptions options) _handler;
  // Copie des en-têtes au moment de l'envoi : une requête rejouée réutilise
  // le même RequestOptions, garder la référence fausserait l'historique.
  final sentHeaders = <Map<String, dynamic>>[];

  @override
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<Uint8List>? requestStream,
    Future<void>? cancelFuture,
  ) {
    sentHeaders.add(Map.of(options.headers));
    return _handler(options);
  }

  @override
  void close({bool force = false}) {}
}

ResponseBody jsonBody(String json, int status) => ResponseBody.fromString(
      json,
      status,
      headers: {
        Headers.contentTypeHeader: [Headers.jsonContentType],
      },
    );

void main() {
  test('rejoue la requête avec le nouveau token après TOKEN_EXPIRED', () async {
    var token = 'old';
    var refreshCalls = 0;
    final adapter = FakeAdapter((options) async {
      if (options.headers['Authorization'] == 'Bearer old') {
        return jsonBody('{"error":{"code":"TOKEN_EXPIRED","message":"expiré"}}', 401);
      }
      return jsonBody('{"balance":125000,"currency":"MAD"}', 200);
    });
    final dio = Dio(BaseOptions(baseUrl: 'http://test.local/api/v1'))
      ..httpClientAdapter = adapter;
    dio.interceptors.add(
      AuthInterceptor(
        dio: dio,
        readToken: () async => token,
        refresh: () async {
          refreshCalls++;
          return token = 'new';
        },
      ),
    );

    final response = await dio.get<Map<String, dynamic>>('/wallet');

    expect(response.data!['balance'], 125000);
    expect(refreshCalls, 1);
    expect(
      adapter.sentHeaders.map((h) => h['Authorization']),
      ['Bearer old', 'Bearer new'],
    );
  });
}

L'intercepteur testé ici est une version courte de celui du chapitre 09 : il ajoute le token en onRequest, et sur un 401 TOKEN_EXPIRED appelle refresh puis rejoue la requête une seule fois. Les cas à couvrir ensuite : deux requêtes concurrentes ne déclenchent qu'un refresh, REFRESH_INVALID déconnecte, une requête rejouée qui échoue encore n'est pas rejouée en boucle.

Tester l'accessibilité

Les matchers de sémantique transforment l'accessibilité en exigence vérifiée en CI, argument précieux pour le secteur public.

testWidgets('le bouton Envoyer est accessible', (tester) async {
  final handle = tester.ensureSemantics();
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: Center(child: FilledButton(onPressed: () {}, child: const Text('Envoyer'))),
      ),
    ),
  );

  // isSemantics ne vérifie que les propriétés passées (pas de valeurs par défaut).
  expect(
    tester.getSemantics(find.byType(FilledButton)),
    isSemantics(label: 'Envoyer', isButton: true, hasTapAction: true),
  );
  // Recommandations de plateforme intégrées.
  await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
  await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
  await expectLater(tester, meetsGuideline(textContrastGuideline));
  handle.dispose();
});

// Annonce vocale (par exemple « Transfert effectué ») envoyée par le widget :
// expect(tester.takeAnnouncements(), contains(isAccessibilityAnnouncement('Transfert effectué')));
Nouveau en 2026

Flutter 3.41 a ajouté isSemantics, une variante de matchesSemantics qui ne vérifie que les propriétés fournies, et isAccessibilityAnnouncement, qui s'utilise avec tester.takeAnnouncements() pour vérifier les annonces envoyées aux lecteurs d'écran.

Couverture et CI

# Génère coverage/lcov.info
flutter test --coverage
# Retire le code généré du calcul (lcov 2.x peut demander --ignore-errors unused)
lcov --remove coverage/lcov.info '*.g.dart' '*.freezed.dart' -o coverage/lcov.info
# Rapport HTML lisible
genhtml coverage/lcov.info -o coverage/html
- run: flutter pub get
- run: dart format --output=none --set-exit-if-changed .
- run: flutter analyze
- run: flutter test --coverage --exclude-tags golden
- run: flutter test --tags golden   # runner Linux uniquement
# puis envoi de coverage/lcov.info à SonarQube ou équivalent

Déclarez le tag dans dart_test.yaml (tags: golden:) pour éviter les avertissements. Les tests d'intégration tournent en général dans un job séparé, sur émulateur ou ferme d'appareils, la nuit ou avant une release, car ils sont lents.

TDD pragmatique

Le TDD strict (test rouge, code minimal, refactoring) est très efficace sur la logique pure : règles de validation, mappers d'erreurs, calculs de montants, redirections, notifiers. Il l'est beaucoup moins sur une UI dont le design évolue chaque jour. Une approche défendable en entretien : TDD pour le domaine et les cas d'erreur, tests de widgets écrits une fois l'écran stabilisé, goldens pour les composants du design system, et un test de non-régression écrit avant chaque correction de bug, pour prouver le bug puis sa correction.

À dire en entretien

« Sur un projet bancaire, je vise beaucoup de tests unitaires sur le domaine et les notifiers, des tests de widgets sur chaque écran avec les providers surchargés, des goldens sur les composants partagés et quelques tests d'intégration sur les parcours critiques. La CI bloque le merge si un test échoue ou si la couverture du nouveau code passe sous le seuil de la porte qualité. Et pour chaque bug corrigé, j'écris d'abord le test qui le reproduit. »

Questions d'entretien

Quelle différence entre test unitaire, test de widget et test d'intégration ?

Unitaire : une unité de logique, sans framework UI, en millisecondes. Widget : un widget ou un écran monté dans un environnement de test sans appareil, avec layout réel, interactions simulées et dépendances surchargées. Intégration : l'app complète sur un appareil ou émulateur, plugins natifs et réseau compris, beaucoup plus lent. On en écrit beaucoup du premier, un par écran du deuxième, quelques-uns du troisième.

Comment testez-vous du code qui dépend du temps ?

Pour les timers et délais : fake_async et elapse, ou tester.pump(duration) dans un test de widget, qui tourne déjà en temps simulé. Pour l'heure courante : injection d'une horloge (fonction ou package clock) plutôt qu'un appel direct à DateTime.now(). Jamais d'attente réelle dans un test.

Comment mockez-vous un provider Riverpod ?

On ne mocke pas le provider : on le surcharge. ProviderContainer.test(overrides: [...]) en test unitaire, ProviderScope(overrides: [...]) en test de widget. On surcharge de préférence la dépendance (repository) avec overrideWithValue et on teste le vrai notifier. Pour remplacer un notifier, on passe une sous-classe à overrideWith.

Comment testez-vous les transitions d'un AsyncNotifier ?

container.listen avec fireImmediately: true, en accumulant les états dans une liste, puis on appelle la méthode et on vérifie la séquence avec isA<AsyncLoading>(), isA<AsyncData>(), isA<AsyncError>() et having. Le listener garde aussi le provider vivant. On désactive le retry automatique de Riverpod 3 dans le conteneur de test.

pump ou pumpAndSettle ?

pump fait une frame, éventuellement après avoir avancé le temps ; il est explicite et prévisible. pumpAndSettle pompe jusqu'à stabilité : pratique après une navigation ou une animation finie, mais il échoue par timeout sur une animation infinie et peut masquer un temps d'attente anormal. Par défaut, je préfère pump explicite.

Mock ou fake : lequel préférez-vous ?

Un fake pour les dépendances que beaucoup de tests utilisent (repository en mémoire) : lisible, réutilisable, il vérifie un état et survit aux refactorings. Un mock (mocktail) quand il faut vérifier une interaction précise, ou l'absence d'interaction, ou pour une interface large dont une seule méthode sert.

Pourquoi vos golden tests échouent-ils en CI alors qu'ils passent en local ?

Rendu du texte et anticrénelage différents selon l'OS, polices différentes, taille de vue ou densité non figées. Solution : générer et vérifier les goldens sur une seule plateforme (celle de la CI ou un conteneur), figer physicalSize et devicePixelRatio, isoler les goldens par un tag, éventuellement utiliser alchemist.

Comment testez-vous un intercepteur Dio ?

Avec un vrai Dio dont on remplace le httpClientAdapter par un fake qui enregistre les requêtes et renvoie des ResponseBody scénarisés (401 puis 200, par exemple). On vérifie les en-têtes envoyés, le nombre de refresh et la réponse finale. Mocker Dio lui-même contournerait les intercepteurs.

Quelle couverture visez-vous et que mesurez-vous vraiment ?

Un seuil de l'ordre de 70 à 80 % sur le domaine et les données, hors code généré, et surtout la couverture du nouveau code dans la porte qualité. Mais la couverture mesure des lignes exécutées, pas des comportements vérifiés : je regarde aussi que les cas d'erreur et les branches métier sont testés avec des assertions significatives.

Que ne peut pas faire integration_test, et que faites-vous dans ce cas ?

Interagir avec l'UI native : dialogues de permission, notifications, biométrie système, navigateur externe. Patrol étend integration_test pour cela. Sinon, on isole ces points derrière des interfaces surchargées en test, et on couvre le natif par des tests manuels ciblés.

Exercices

Exercice 1 · Sans IA · 30 min

Écrire un SearchNotifier (AsyncNotifier) qui interroge un repository de bénéficiaires 300 ms après la dernière saisie, et ses tests : un seul appel pour une rafale de frappes, résultat en AsyncData, erreur en AsyncError. Critères : aucun délai réel (temps simulé), un conteneur par test, retry désactivé, fake écrit à la main.

Exercice 2 · Sans IA · 30 min

Test de widget de l'écran de connexion : champ vide, puis mot de passe invalide (erreur INVALID_CREDENTIALS renvoyée par un fake), puis succès avec navigation vers l'accueil. Critères : finders par Key, aucune requête réseau réelle, pas de pumpAndSettle pendant que l'indicateur de chargement est visible, au moins une assertion isSemantics.

Exercice 3 · Sans IA · 20 min

Tester avec un FakeAdapter qu'un 401 REFRESH_INVALID pendant le refresh vide le stockage des tokens et ne rejoue pas la requête. Critères : nombre exact de requêtes vérifié, aucune boucle, test en moins de 100 ms.

Projet fil rouge · étape 12

Ajouter les tests de l'app Wallet (voir le projet). Unitaires : TransferNotifier (succès, INSUFFICIENT_FUNDS, double tap, même Idempotency-Key sur un rejeu), redirection du router, intercepteur d'auth avec FakeAdapter. Widget : écran Historique avec liste, état vide, erreur réessayable et chargement de la page suivante. Golden : TransactionTile en crédit, débit et en attente, en français et en arabe. Intégration : connexion avec le compte de démo 0600000001 / demo1234 contre le backend local, puis affichage du solde. Validation : flutter test --coverage vert, couverture hors code généré au-dessus de 70 % sur features/*/domain et features/*/data.

Quiz

12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.

Pour aller plus loin