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 naturelles —
groupde haut niveau pour la classe,groupimbriqué pour la méthode,testpour le comportement (ex. :UserRepository→getUser→returns 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/tearDownse trouvent à l'intérieur d'ungroup, jamais au niveau supérieur demain() - Initialiser les objets mutables dans
setUp()aveclate— déclarezlate MyDep dep;puis assignez danssetUpafin 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— jamaispackage:mockito - Tags de test constants — utilisez une
abstract class TestTagavec des champsstatic 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é danstest/helpers/pump_app.dart; ne mettez jamaispumpWidget(MaterialApp(...))en ligne - Taggez tous les tests golden — annotez avec
TestTag.goldenpour que les goldens puissent s'exécuter/se mettre à jour indépendamment - Passez
directoryà l'outil MCPtestsi 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écessitentdirectory: 'mobile'; omettez-le seulement quandpubspec.yamlest à 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.dart → test/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 randompour exposer les dépendances cachées - Toute la logique de setup appartient aux blocs
group, jamais au niveaumain() - 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
- references/widget-tests.md — structure des tests de widgets et test des thèmes/localisations
- references/golden-tests.md — test des fichiers golden (setup, écriture des goldens, tagging, exécution/mise à jour, anti-motifs)
- references/matchers.md — référence rapide des matchers
- references/configuration.md — configuration
dart_test.yaml(tags, commandes, surcharges de plateforme) - references/coverage.md — motifs de couverture et référence des packages/imports
- references/animation-testing.md — test des animations implicites/explicites, AnimatedSwitcher, transitions de page et contrôleurs injectés