testing

Par verygoodopensource · vgv-ai-flutter-plugin

Bonnes pratiques pour les tests unitaires Dart, les tests de widgets Flutter et les tests de golden files.

npx skills add https://github.com/verygoodopensource/vgv-ai-flutter-plugin --skill testing

Tests Dart & Flutter

Principes fondamentaux pour les tests Dart et Flutter — tests unitaires, tests de widgets et tests de fichiers golden — utilisant package:test, package:flutter_test, package:mocktail et package:bloc_test.

Normes essentielles

Appliquez ces normes à TOUS les travaux de test :

  • Noms de tests descriptifs — verbeux, lisibles, décrivant le comportement ; jamais 'works' ou 'renders'
  • Structure hiérarchique group/test qui se lit comme des phrases naturellesgroup de haut niveau pour la classe, group imbriqué pour la méthode, test pour le comportement (ex. : UserRepositorygetUserreturns User when API succeeds)
  • Interpolation de chaîne pour les références de type — utilisez 'returns $User' et non 'returns User' pour que les renommages se propagent automatiquement
  • Mocks privés par fichier — déclarez class _MockX extends Mock implements X {} avec préfixe underscore pour éviter les couplages entre fichiers
  • Setup de test contenu dans les groups — tous les appels setUp/tearDown se trouvent à l'intérieur d'un group, jamais au niveau supérieur de main()
  • Initialiser les objets mutables dans setUp() avec late — déclarez late MyDep dep; puis assignez dans setUp afin que chaque test obtienne une instance fraîche
  • Pas d'état mutable partagé entre les tests — n'utilisez jamais de membres statiques, variables globales ou instances finales de haut niveau qui persistent entre les tests
  • Utilisez package:mocktail — jamais package:mockito
  • Tags de test constants — utilisez une abstract class TestTag avec des champs static const ; ne passez jamais de littéraux de chaîne bruts comme tags
  • Testez le comportement, pas les propriétés — les tests de widgets se concentrent sur les résultats fonctionnels ; les propriétés visuelles statiques sont validées par des tests golden
  • Utilisez le helper de test pumpApp — enveloppez les widgets via un helper partagé dans test/helpers/pump_app.dart ; ne mettez jamais pumpWidget(MaterialApp(...)) en ligne
  • Taggez tous les tests golden — annotez avec TestTag.golden pour que les goldens puissent s'exécuter/se mettre à jour indépendamment
  • Passez directory à l'outil MCP test si le projet n'est pas à la racine de l'espace de travail — les monorepos avec le projet Flutter dans un sous-répertoire (ex. : mobile/) nécessitent directory: 'mobile' ; omettez-le seulement quand pubspec.yaml est à la racine de l'espace de travail

Structure des tests

Organisation des fichiers

Convention Règle
Suffixe du fichier Chaque fichier de test se termine par _test.dart
Répertoire Tous les tests vivent sous test/
Structure miroir test/ reflète lib/ exactement — lib/src/models/user.darttest/src/models/user_test.dart
Helpers Les utilitaires de test partagés vont dans test/helpers/ (ex. : pump_app.dart, fakes.dart)

Hiérarchie des groupes et tests

Structurez les groupes de sorte que les descriptions concaténées se lisent comme des phrases naturelles. Utilisez des références de type en PascalCase dans le groupe de haut niveau.

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

import 'package:my_app/user_repository.dart';

class _MockApiClient extends Mock implements ApiClient {}

void main() {
  group(UserRepository, () {
    late ApiClient apiClient;
    late UserRepository subject;

    setUp(() {
      apiClient = _MockApiClient();
      subject = UserRepository(apiClient: apiClient);
    });

    group('getUser', () {
      test('returns $User when API call succeeds', () async {
        when(() => apiClient.fetchUser(any()))
            .thenAnswer((_) async => User(id: '1', name: 'Dash'));

        final result = await subject.getUser('1');

        expect(result, equals(User(id: '1', name: 'Dash')));
        verify(() => apiClient.fetchUser('1')).called(1);
      });

      test('throws $UserNotFoundException when API returns 404', () {
        when(() => apiClient.fetchUser(any()))
            .thenThrow(ApiException(statusCode: 404));

        expect(
          () => subject.getUser('1'),
          throwsA(isA<UserNotFoundException>()),
        );
      });
    });

    group('deleteUser', () {
      test('calls apiClient.deleteUser with correct id', () async {
        when(() => apiClient.deleteUser(any()))
            .thenAnswer((_) async {});

        await subject.deleteUser('1');

        verify(() => apiClient.deleteUser('1')).called(1);
      });
    });
  });
}

Conventions de nommage

Motif Exemple
Retourne une valeur 'returns $User when API call succeeds'
Lève une exception 'throws $UserNotFoundException when user is not found'
Appelle une dépendance 'calls apiClient.deleteUser with correct id'
Émet des états 'emits [loading, success] when data is fetched'
Comportement conditionnel 'returns cached value when cache is not expired'
Cas limite 'returns empty list when repository has no items'

Méthodes de cycle de vie

Méthode S'exécute Utilisez pour
setUp Avant chaque test Créer des mocks frais, instancier le sujet de test
tearDown Après chaque test Fermer des streams, réinitialiser des singletons, disposer des contrôleurs
setUpAll Une fois avant tous les tests du groupe Enregistrer les valeurs par défaut, initialisation coûteuse unique
tearDownAll Une fois après tous les tests du groupe Libérer les ressources partagées (ex. : connexions de base de données)

Motif correct pour setUp

Utilisez toujours late + setUp à l'intérieur d'un groupe pour les dépendances mutables :

group(AuthService, () {
  late AuthRepository authRepository;
  late TokenStorage tokenStorage;
  late AuthService subject;

  setUp(() {
    authRepository = _MockAuthRepository();
    tokenStorage = _MockTokenStorage();
    subject = AuthService(
      authRepository: authRepository,
      tokenStorage: tokenStorage,
    );
  });

  test('authenticates with valid credentials', () async {
    when(() => authRepository.signIn(any(), any()))
        .thenAnswer((_) async => Token('abc'));
    when(() => tokenStorage.save(any()))
        .thenAnswer((_) async {});

    await subject.signIn('user', 'pass');

    verify(() => tokenStorage.save(Token('abc'))).called(1);
  });
});

setUpAll vs setUp

Utilisez setUpAll pour un setup coûteux et immuable — le plus souvent registerFallbackValue :

group(OrderRepository, () {
  late ApiClient apiClient;

  setUpAll(() {
    registerFallbackValue(Order(id: '', items: const []));
    registerFallbackValue(Uri());
  });

  setUp(() {
    apiClient = _MockApiClient();
  });

  // tests...
});

registerFallbackValue n'a besoin de s'exécuter qu'une fois car il enregistre un type globalement pour les matchers any().

Mocking avec Mocktail

Création de mocks

Déclarez les mocks comme classes privées en bas du fichier de test (ou en haut, avant main) :

class _MockUserRepository extends Mock implements UserRepository {}

class _MockAnalyticsClient extends Mock implements AnalyticsClient {}

class _FakeUser extends Fake implements User {}

Utilisez Fake quand vous avez besoin d'une implémentation concrète qui lève une exception sur les méthodes non implémentées plutôt que de retourner null.

Stubbing de méthodes

Méthode À utiliser pour Exemple
thenReturn Valeurs de retour synchrones when(() => mock.name).thenReturn('Dash');
thenAnswer Retours async / Future / Stream when(() => mock.fetch()).thenAnswer((_) async => data);
thenThrow Lever des exceptions when(() => mock.fetch()).thenThrow(Exception('fail'));

Pour les streams :

when(() => mock.updates).thenAnswer((_) => Stream.fromIterable([1, 2, 3]));

Matchers d'arguments

Matcher Objectif Exemple
any() Correspond à n'importe quelle valeur (nécessite registerFallbackValue pour les types personnalisés) when(() => mock.fetch(any()))
any(that: matcher) Correspond aux valeurs satisfaisant un matcher when(() => mock.fetch(any(that: isA<String>())))
captureAny() Capture l'argument pour inspection ultérieure verify(() => mock.save(captureAny()))

Capture d'arguments pour assertion :

test('passes the correct user to the repository', () async {
  when(() => repository.save(any())).thenAnswer((_) async {});

  await subject.createUser(name: 'Dash');

  final captured = verify(() => repository.save(captureAny())).captured;
  expect(captured.first, isA<User>().having((u) => u.name, 'name', 'Dash'));
});

Vérification

Méthode Objectif
verify(() => mock.method()).called(n) Vérifier que la méthode a été appelée exactement n fois
verifyNever(() => mock.method()) Vérifier que la méthode n'a jamais été appelée
verifyNoMoreInteractions(mock) Vérifier qu'aucune autre méthode n'a été appelée sur le mock
verifyInOrder([...]) Vérifier que les méthodes ont été appelées dans un ordre spécifique

Enregistrement des valeurs par défaut

Enregistrez une valeur par défaut pour chaque type personnalisé utilisé avec any() ou captureAny() :

setUpAll(() {
  registerFallbackValue(User(id: '', name: ''));
  registerFallbackValue(Uri.parse('https://example.com'));
});

La valeur par défaut n'est utilisée que quand aucun stub ne correspond — ses valeurs de champ spécifiques n'importent pas.

Isolation des tests

Principes

  • Chaque test doit passer quand exécuté individuellement, dans n'importe quel ordre et en parallèle
  • Utilisez --test-randomize-ordering-seed random pour exposer les dépendances cachées
  • Toute la logique de setup appartient aux blocs group, jamais au niveau main()
  • Les mocks sont privés au fichier — ne jamais importer des mocks depuis un autre fichier de test

Anti-motifs

Anti-motif Problème Approche correcte
setUp au niveau supérieur de main() S'éloigne quand l'exécuteur de tests fusionne les fichiers pour optimiser Déplacez setUp à l'intérieur d'un group
final dep = _MockDep(); (niveau supérieur) La même instance est partagée entre tous les tests ; fuite d'état Utilisez late + setUp à l'intérieur d'un groupe
class MockDep extends Mock (public) D'autres fichiers de test peuvent l'importer et en dépendre Utilisez class _MockDep extends Mock (privé)
Variables statiques/globales mutables L'état persiste entre les tests Réinitialisez dans setUp ou évitez complètement
Tests qui doivent s'exécuter dans un ordre spécifique Fragile, échoue avec ordre aléatoire Rendez chaque test complètement autonome

Motifs de test courants

Tests de méthodes async

test('returns list of users from API', () async {
  when(() => apiClient.fetchUsers())
      .thenAnswer((_) async => [User(id: '1', name: 'Dash')]);

  final result = await subject.getUsers();

  expect(result, hasLength(1));
  expect(result.first.name, equals('Dash'));
});

Tests de streams

test('emits updated values when data changes', () {
  when(() => repository.watch())
      .thenAnswer((_) => Stream.fromIterable([1, 2, 3]));

  expect(
    subject.valueStream,
    emitsInOrder([1, 2, 3]),
  );
});

Tests d'exceptions

test('throws $FormatException when input is invalid', () {
  expect(
    () => subject.parse('invalid'),
    throwsA(
      isA<FormatException>().having(
        (e) => e.message,
        'message',
        contains('invalid'),
      ),
    ),
  );
});

Tests avec Equatable

Quand la classe étend Equatable, affirmez directement avec equals :

test('returns expected $User', () async {
  when(() => apiClient.fetchUser('1'))
      .thenAnswer((_) async => User(id: '1', name: 'Dash'));

  final result = await subject.getUser('1');

  expect(result, equals(User(id: '1', name: 'Dash')));
});

Test de logique privée via API publique

Ne testez jamais directement les méthodes privées. Exercez la logique privée via la méthode publique qui l'utilise :

// Si _normalizeEmail est privée, testez-la via la méthode publique createUser :
test('normalizes email to lowercase before saving', () async {
  when(() => repository.save(any())).thenAnswer((_) async {});

  await subject.createUser(email: 'Dash@Example.COM');

  final captured = verify(() => repository.save(captureAny())).captured;
  expect(captured.first.email, equals('dash@example.com'));
});

Tests de callbacks

test('calls onSuccess callback when operation completes', () async {
  var callbackCalled = false;
  when(() => repository.save(any())).thenAnswer((_) async {});

  await subject.save(
    data: 'test',
    onSuccess: () => callbackCalled = true,
  );

  expect(callbackCalled, isTrue);
});

Tests de widgets

Les tests de widgets vérifient que les widgets Flutter se comportent correctement — rendu du bon contenu, réponse aux interactions utilisateur et navigation comme prévu. Ils s'exécutent dans un environnement simulé sans véritable appareil.

Normes

Règle Détails
Utilisez testWidgets Chaque test de widget utilise testWidgets au lieu de test
Préférez find.byType Finder par défaut ; utilisez find.text pour le contenu visible par l'utilisateur, find.byKey seulement quand le type/texte est ambigu
Groupez par catégorie de comportement Utilisez renders, navigates, calls [MethodName], updates comme noms de groupes imbriqués
Concentrez-vous sur le comportement Affirmez ce que le widget fait (affiche du texte, appelle un callback, navigue) ; utilisez des tests golden pour l'apparence visuelle
Mockez les Blocs et Cubits Utilisez MockBloc/MockCubit de package:bloc_test ; ne fournissez jamais de vrais Blocs dans les tests de widgets

Helper pumpApp

Créez un helper pumpApp partagé pour que chaque test de widget enveloppe le widget testé de manière cohérente :

// test/helpers/pump_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

extension PumpApp on WidgetTester {
  Future<void> pumpApp(Widget widget) {
    return pumpWidget(
      MaterialApp(
        home: widget,
      ),
    );
  }
}

Exportez-le depuis un fichier barrel pour que chaque test puisse l'importer avec une seule ligne :

// test/helpers/helpers.dart
export 'pump_app.dart';

Utilisation dans les tests :

import '../helpers/helpers.dart';

void main() {
  group(MyWidget, () {
    testWidgets('renders greeting text', (tester) async {
      await tester.pumpApp(const MyWidget());

      expect(find.text('Hello'), findsOneWidget);
    });
  });
}

Méthodes de pompage

Méthode Quand utiliser
pumpWidget(widget) Rendu initial — construit l'arborescence de widgets pour la première fois
pump() Déclencher une seule reconstruction de frame (après setState, tap, etc.)
pump(Duration) Avancer le temps d'une durée spécifique (animations, debounce)
pumpAndSettle() Pomper de manière répétée jusqu'à ce qu'aucun frame n'attende — utilisez pour les animations qui doivent s'achever

Préférez pump() à pumpAndSettle()pumpAndSettle peut se bloquer quand des animations infinies (ex. : CircularProgressIndicator) sont présentes. Utilisez pump() pour les reconstructions discrètes.

Finders

Finder Cas d'usage Exemple
find.byType(T) Trouver les widgets par type (choix par défaut) find.byType(ElevatedButton)
find.text('x') Trouver le contenu textuel visible par les utilisateurs find.text('Submit')
find.byKey(Key) Trouver par clé explicite (dernier recours) find.byKey(Key('submit_button'))
find.byWidget(w) Trouver une instance de widget exacte find.byWidget(myWidget)
find.descendant(of, matching) Recherche limitée dans un sous-arbre find.descendant(of: find.byType(AppBar), matching: find.text('Title'))

Interactions

// Appuyer
await tester.tap(find.byType(ElevatedButton));
await tester.pump();

// Entrer du texte
await tester.enterText(find.byType(TextField), 'hello@example.com');
await tester.pump();

// Glisser / faire défiler
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pump();

// Appui long
await tester.longPress(find.byType(ListTile));
await tester.pump();

Appelez toujours pump() (ou pumpAndSettle()) après chaque interaction — les widgets ne se reconstruisent pas avant qu'un frame soit déclenché.

Anti-motifs des tests de widgets

Anti-motif Problème Approche correcte
MaterialApp en ligne dans chaque test Boilerplate dupliqué ; setup inconsistant Utilisez le helper pumpApp
find.byKey comme finder par défaut Couple les tests aux clés d'implémentation Préférez find.byType ou find.text
Tester le padding, les couleurs ou les tailles de police Fragile ; s'éloigne lors des modifications de design ; non comportemental Utilisez les tests golden pour la validation visuelle
pump() manquant après interaction L'arborescence de widgets ne se reconstruit pas ; l'assertion voit l'état périmé Toujours pump() après tap, enterText, etc.
Vrais Blocs dans les tests de widgets Les tests deviennent des tests d'intégration ; lents, fragiles, difficiles à isoler Utilisez MockBloc/MockCubit de bloc_test

Ressources supplémentaires

Skills similaires