Kodokon kodokon.com

Tests : unitaires Dart et tests de widgets

Testez logique et widgets avec des fakes écrits main, une horloge simulée et les bons réflexes de pump.

11 min · 3 questions

Ouvrir cette leçon dans Kodokon

La pyramide de tests Flutter : une large base de tests unitaires Dart purs, une couche de tests de widgets, quelques tests d'intégration. Un test unitaire ne devrait jamais importer material.dart - si votre logique l'exige, le problème est architectural, pas technique. Pour isoler le code de ses dépendances, préférez les fakes écrits à la main aux mocks générés : un fake est une implémentation simplifiée mais réellement fonctionnelle du contrat (repository en mémoire, horloge pilotable) ; il survit aux refactorings et se lit comme du code normal. Les mocks, qui vérifient les interactions (« cette méthode a été appelée deux fois »), couplent le test aux détails d'implémentation - à réserver aux protocoles où l'interaction est le comportement.

DART
import 'package:flutter/foundation.dart';

abstract interface class ScoreRepository {
  Future<int> fetchBest();
}

class ScoreController extends ChangeNotifier {
  ScoreController(this._repository);

  final ScoreRepository _repository;

  int? best;
  Object? error;

  Future<void> load() async {
    try {
      best = await _repository.fetchBest();
      error = null;
    } on Exception catch (e) {
      error = e;
    }
    notifyListeners();
  }
}
Le code à tester : aucun import de material.dart.

setUp recrée les objets avant chaque test : aucun état partagé, donc aucun test qui dépend de l'ordre d'exécution - c'est la condition de leur fiabilité. Les matchers composables (isA<Exception>(), throwsA, isNull) produisent des messages d'échec précis, bien plus utiles qu'un booléen. Remarquez le second test ci-dessous : il vérifie l'échec puis le rétablissement dans la foulée. Les chemins d'erreur sont du code comme les autres - s'ils ne sont pas testés, considérez-les comme faux.

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

class FakeScoreRepository implements ScoreRepository {
  int calls = 0;
  bool failNextCall = false;

  @override
  Future<int> fetchBest() async {
    calls++;
    if (failNextCall) {
      failNextCall = false;
      throw Exception('réseau indisponible');
    }
    return 42;
  }
}

void main() {
  late FakeScoreRepository repository;
  late ScoreController controller;

  setUp(() {
    repository = FakeScoreRepository();
    controller = ScoreController(repository);
  });

  test('expose le meilleur score', () async {
    await controller.load();

    expect(controller.best, 42);
    expect(controller.error, isNull);
    expect(repository.calls, 1);
  });

  test('capture une erreur puis se rétablit', () async {
    repository.failNextCall = true;

    await controller.load();
    expect(controller.error, isA<Exception>());

    await controller.load();
    expect(controller.best, 42);
    expect(controller.error, isNull);
  });

  test('notifie ses écouteurs', () async {
    var notifications = 0;
    controller.addListener(() => notifications++);

    await controller.load();

    expect(notifications, 1);
  });
}
Un fake de quinze lignes remplace toute une bibliothèque de mocks.

Un test de widget ne rend rien à l'écran : il tourne dans un binding spécialisé (AutomatedTestWidgetsFlutterBinding) où le temps est simulé et où chaque frame est produite à la demande. pumpWidget monte l'arbre ; pump() avance l'horloge factice et produit une frame ; pump(const Duration(milliseconds: 150)) saute dans le temps - idéal pour figer un état intermédiaire d'animation et l'inspecter. Les Finder (find.text, find.byType, find.byKey) interrogent l'arbre réel des Elements, pas une capture d'écran. Comme rien n'est asynchrone pour de vrai - ni horloge ni réseau -, ces tests sont déterministes : un échec est toujours reproductible.

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

class BadgeScreen extends StatefulWidget {
  const BadgeScreen({super.key});

  @override
  State<BadgeScreen> createState() => _BadgeScreenState();
}

class _BadgeScreenState extends State<BadgeScreen> {
  bool _visible = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          if (_visible) const Text('Nouveau'),
          IconButton(
            icon: const Icon(Icons.close),
            onPressed: () =>
                setState(() => _visible = false),
          ),
        ],
      ),
    );
  }
}

void main() {
  testWidgets('masque le badge après le tap',
      (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: BadgeScreen()),
    );
    expect(find.text('Nouveau'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.close));
    await tester.pump();

    expect(find.text('Nouveau'), findsNothing);
  });
}
tap ne déclenche pas de frame : le pump qui suit est requis.
BASH
flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens
Ciblez un fichier, mesurez la couverture, régénérez les goldens.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Pourquoi pumpAndSettle échoue-t-il face à une animation lancée avec repeat() ?
    • Il pompe des frames tant qu'il en reste de planifiées : une animation infinie ne se stabilise jamais, d'où un timeout
    • repeat() n'est pas supporté par le binding de test
    • pumpAndSettle ne gère que les animations implicites
  2. Quelle est la différence entre un fake et un mock ?
    • Le fake est une implémentation simplifiée mais fonctionnelle ; le mock sert surtout à vérifier les interactions
    • Le mock est écrit à la main, le fake est généré par un outil
    • Aucune : les deux termes sont synonymes
    • Le fake ne peut pas implémenter une interface Dart
  3. Dans un test de widget, que fait exactement await tester.pump() ?
    • Il avance l'horloge simulée et produit une seule nouvelle frame
    • Il attend que toutes les animations en cours se terminent
    • Il relance le test depuis le début
    • Il attend la fin des vraies requêtes réseau