vgv-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 vgv-testing

Tests Dart & Flutter

Fondamentaux des tests pour les projets 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.

Standards de base

Appliquez ces standards à TOUS les travaux de test :

  • Noms de tests explicites — noms verbeux et lisibles qui décrivent 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 (par exemple, UserRepositorygetUserreturns User when API succeeds)
  • Interpolation de chaîne pour les références de types — utilisez 'returns $User' et non 'returns User' pour que les renamings se propagent automatiquement
  • Mocks privés par fichier — déclarez class _MockX extends Mock implements X {} avec le préfixe underscore pour éviter les couplages entre fichiers
  • Setup des tests contenu dans les groups — tous les appels setUp/tearDown vivent dans un group, jamais au niveau top-level de main()
  • Initialiser les objets mutables dans setUp() avec late — déclarez late MyDep dep; puis assignez dans setUp pour 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 top-level 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 chaînes littérales brutes 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 via les tests golden
  • Utilisez le helper de test pumpApp — enrobez les widgets via un helper partagé dans test/helpers/pump_app.dart ; n'insérez jamais directement pumpWidget(MaterialApp(...))
  • Marquez 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 quand le projet n'est pas à la racine du workspace — les monorepos avec le projet Flutter dans un sous-répertoire (par exemple mobile/) nécessitent directory: 'mobile' ; omettez-le seulement quand pubspec.yaml est à la racine du workspace

Structure des tests

Organisation des fichiers

Convention Règle
Suffixe de 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/ (par exemple, pump_app.dart, fakes.dart)

Hiérarchie des groups et tests

Structurez les groups pour que les descriptions concaténées se lisent comme des phrases naturelles. Utilisez les références de type en PascalCase dans le group 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

Pattern 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 À utiliser pour
setUp Avant chaque test Créer des mocks frais, instancier le subject under test
tearDown Après chaque test Fermer les streams, réinitialiser les singletons, disposer les contrôleurs
setUpAll Une fois avant tous les tests du group Enregistrer les fallback values, initialisation coûteuse unique
tearDownAll Une fois après tous les tests du group Libérer les ressources partagées (par exemple, les connexions aux bases de données)

Pattern setUp correct

Utilisez toujours late + setUp à l'intérieur d'un group 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 seule fois car il enregistre un type globalement pour les matchers any().

Mocking avec Mocktail

Créer des mocks

Déclarez les mocks comme des classes privées à la fin du fichier de test (ou au début, 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.

Stubber les 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 à toute 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()))

Capturer les arguments pour l'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) Affirmer que la méthode a été appelée exactement n fois
verifyNever(() => mock.method()) Affirmer que la méthode n'a jamais été appelée
verifyNoMoreInteractions(mock) Affirmer qu'aucune autre méthode n'a été appelée sur le mock
verifyInOrder([...]) Affirmer que les méthodes ont été appelées dans un ordre spécifique

Enregistrer les fallback values

Enregistrez une fallback value pour chaque type personnalisé utilisé avec any() ou captureAny() :

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

La fallback value n'est utilisée que quand aucun stub ne correspond — ses valeurs de champs spécifiques n'ont pas d'importance.

Isolation des tests

Principes

  • Chaque test doit réussir quand il s'exécute 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 à l'intérieur des blocs group, jamais au niveau main()
  • Les mocks sont privés au fichier — n'importez jamais de mocks d'un autre fichier de test

Anti-patterns

Anti-pattern Problème Approche correcte
setUp au niveau top-level de main() Casse quand le test runner fusionne les fichiers pour optimisation Déplacez setUp à l'intérieur d'un group
final dep = _MockDep(); (top-level) La même instance est partagée entre tous les tests ; fuite d'état Utilisez late + setUp à l'intérieur d'un group
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-le complètement
Tests qui doivent s'exécuter dans un ordre spécifique Fragile, échoue avec un ordre aléatoire Rendez chaque test entièrement autonome

Patterns de test courants

Tester les 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'));
});

Tester les streams

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

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

Tester les exceptions

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

Tester avec Equatable

Quand la classe étend Equatable, assertez 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')));
});

Tester la logique privée via l'API publique

Ne testez jamais les méthodes privées directement. 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'));
});

Tester les 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 — rendent le contenu approprié, réagissent aux interactions de l'utilisateur et naviguent comme prévu. Ils s'exécutent dans un environnement simulé sans dispositif réel.

Standards

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 à 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 groups imbriqués
Concentrez-vous sur le comportement Assertez ce que le widget fait (affiche du texte, appelle une callback, navigue) ; utilisez les tests golden pour l'apparence visuelle
Mocquez 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 enroule 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 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 pump

Méthode Quand l'utiliser
pumpWidget(widget) Rendu initial — construit l'arbre de widgets pour la première fois
pump() Déclencher un simple rebuild de frame (après setState, tap, etc.)
pump(Duration) Avancer le temps d'une durée spécifique (animations, debounce)
pumpAndSettle() Pump de manière répétée jusqu'à ce qu'aucun frame ne soit en attente — utilisez pour les animations qui doivent se terminer

Préférez pump() à pumpAndSettle()pumpAndSettle peut se bloquer quand des animations infinies (par exemple, CircularProgressIndicator) sont présentes. Utilisez pump() pour les rebuilds discrets.

Finders

Finder Cas d'usage Exemple
find.byType(T) Trouver des widgets par type (choix par défaut) find.byType(ElevatedButton)
find.text('x') Trouver du contenu textuel visible aux 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 ciblée dans un sous-arbre find.descendant(of: find.byType(AppBar), matching: find.text('Title'))

Interactions

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

// Enter text
await tester.enterText(find.byType(TextField), 'hello@example.com');
await tester.pump();

// Drag / scroll
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pump();

// Long press
await tester.longPress(find.byType(ListTile));
await tester.pump();

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

Anti-patterns des tests de widgets

Anti-pattern Problème Approche correcte
MaterialApp en ligne dans chaque test Boilerplate dupliqué ; setup incohérent 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 polices Fragile ; casse sur les retouches de design ; pas comportemental Utilisez les tests golden pour la validation visuelle
pump() manquant après interaction L'arbre de widgets ne se reconstruit pas ; l'assertion voit un é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