Tests en Flutter
Introduction aux tests
Les tests unitaires sont essentiels pour garantir la qualité et la fiabilité de vos applications Flutter. Ils permettent de tester des portions isolées de code, comme des fonctions, des méthodes ou des classes.
Pourquoi écrire des tests ?
- Détection précoce des erreurs : Identifier les bugs dès le développement
- Refactorisation sûre : Modifier le code en toute confiance
- Documentation vivante : Les tests montrent comment utiliser le code
- Amélioration de la conception : Encourage un code modulaire et testable
- Réduction des coûts : Moins cher de corriger tôt que tard
Types de tests
| Type | Portée | Vitesse | Couverture |
|---|---|---|---|
| Unitaires | Fonction/classe isolée | Très rapide | Logique métier |
| Widgets | Un widget | Rapide | UI composants |
| Intégration | Application complète | Lent | Flux complets |
Le package flutter_test
Flutter inclut flutter_test par défaut dans chaque projet, aucune configuration supplémentaire n'est nécessaire.
Structure d'un projet
mon_projet/
├── lib/
│ └── mon_code.dart
└── test/
└── mon_code_test.dart
Convention : Les fichiers de test se terminent par _test.dart et sont dans le dossier test/.
Écrire votre premier test
Exemple simple
Code à tester (lib/calculator.dart) :
class Calculator {
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
double divide(int a, int b) {
if (b == 0) {
throw ArgumentError('Division par zéro impossible');
}
return a / b;
}
}
Tests (test/calculator_test.dart) :
import 'package:flutter_test/flutter_test.dart';
import 'package:mon_projet/calculator.dart';
void main() {
group('Calculator', () {
test('Addition de deux nombres positifs', () {
final calculator = Calculator();
expect(calculator.add(2, 3), 5);
});
test('Addition avec un nombre négatif', () {
final calculator = Calculator();
expect(calculator.add(5, -2), 3);
});
test('Soustraction de deux nombres', () {
final calculator = Calculator();
expect(calculator.subtract(10, 4), 6);
});
test('Division normale', () {
final calculator = Calculator();
expect(calculator.divide(10, 2), 5.0);
});
test('Division par zéro lance une erreur', () {
final calculator = Calculator();
expect(
() => calculator.divide(10, 0),
throwsArgumentError,
);
});
});
}
Structure d'un test
test('Description du test', () {
// 1. Arrange (Préparation)
final calculator = Calculator();
final a = 5;
final b = 3;
// 2. Act (Action)
final resultat = calculator.add(a, b);
// 3. Assert (Vérification)
expect(resultat, 8);
});
Exécuter les tests
Ligne de commande
# Exécuter tous les tests
flutter test
# Exécuter un fichier de test spécifique
flutter test test/calculator_test.dart
# Exécuter avec couverture de code
flutter test --coverage
# Mode watch (réexécution automatique)
flutter test --watch
VS Code
- Cliquez sur "Run" au-dessus de
void main() - Ou utilisez la palette de commandes : "Flutter: Run Tests"
Assertions courantes
Égalité
test('Vérifications d\'égalité', () {
expect(2 + 2, 4);
expect('hello', 'hello');
expect([1, 2, 3], [1, 2, 3]);
expect({'key': 'value'}, {'key': 'value'});
});
Booléens
test('Vérifications booléennes', () {
expect(true, isTrue);
expect(false, isFalse);
expect(5 > 3, isTrue);
});
Null
test('Vérifications null', () {
String? valeur;
expect(valeur, isNull);
valeur = 'test';
expect(valeur, isNotNull);
});
Types
test('Vérifications de type', () {
expect(42, isA<int>());
expect('hello', isA<String>());
expect([1, 2], isA<List<int>>());
});
Exceptions
test('Vérifications d\'exceptions', () {
expect(() => throw Exception(), throwsException);
expect(() => throw ArgumentError(), throwsArgumentError);
expect(() => throw StateError('error'), throwsStateError);
// Exception personnalisée
expect(
() => throw FormatException('Invalid format'),
throwsA(isA<FormatException>()),
);
});
Collections
test('Vérifications de collections', () {
final list = [1, 2, 3, 4, 5];
expect(list, contains(3));
expect(list, containsAll([2, 4]));
expect(list, hasLength(5));
expect(list, isNotEmpty);
expect([], isEmpty);
});
Nombres
test('Vérifications numériques', () {
expect(10, greaterThan(5));
expect(3, lessThan(10));
expect(5, greaterThanOrEqualTo(5));
expect(5, lessThanOrEqualTo(5));
expect(3.14159, closeTo(3.14, 0.01)); // Précision
});
Grouper les tests
void main() {
group('Opérations arithmétiques', () {
group('Addition', () {
test('nombres positifs', () {
expect(2 + 3, 5);
});
test('nombres négatifs', () {
expect(-2 + -3, -5);
});
});
group('Multiplication', () {
test('par zéro', () {
expect(5 * 0, 0);
});
test('nombres positifs', () {
expect(3 * 4, 12);
});
});
});
}
setUp et tearDown
Exécuter du code avant/après chaque test :
void main() {
late Calculator calculator;
// Avant chaque test
setUp(() {
calculator = Calculator();
});
// Après chaque test
tearDown(() {
// Nettoyer les ressources
});
test('Addition', () {
expect(calculator.add(2, 3), 5);
});
test('Soustraction', () {
expect(calculator.subtract(5, 2), 3);
});
}
setUpAll et tearDownAll
Exécuter une seule fois pour tous les tests du groupe :
void main() {
setUpAll(() {
// Initialisation lourde (base de données, etc.)
print('Initialisation globale');
});
tearDownAll(() {
// Nettoyage global
print('Nettoyage global');
});
test('Test 1', () {
// ...
});
test('Test 2', () {
// ...
});
}
Tester du code asynchrone
Future
Future<String> fetchData() async {
await Future.delayed(Duration(seconds: 1));
return 'Données récupérées';
}
test('Tester un Future', () async {
final result = await fetchData();
expect(result, 'Données récupérées');
});
Stream
Stream<int> countStream() async* {
for (int i = 1; i <= 3; i++) {
await Future.delayed(Duration(milliseconds: 100));
yield i;
}
}
test('Tester un Stream', () async {
final values = await countStream().toList();
expect(values, [1, 2, 3]);
});
test('Tester un Stream avec expectLater', () {
expect(
countStream(),
emitsInOrder([1, 2, 3, emitsDone]),
);
});
Mocking avec mockito
Installation
dev_dependencies:
mockito: ^5.4.0
build_runner: ^2.4.0
Exemple avec API
Service API (lib/api_service.dart) :
import 'package:http/http.dart' as http;
import 'dart:convert';
class ApiService {
final http.Client client;
ApiService(this.client);
Future<List<String>> fetchProducts() async {
final response = await client.get(
Uri.parse('https://api.example.com/products'),
);
if (response.statusCode == 200) {
final List<dynamic> json = jsonDecode(response.body) as List;
return json.map((item) => item['name'] as String).toList();
} else {
throw Exception('Failed to load products');
}
}
}
Tests avec mock (test/api_service_test.dart) :
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:http/http.dart' as http;
import 'package:mon_projet/api_service.dart';
// Génère api_service_test.mocks.dart
([http.Client])
import 'api_service_test.mocks.dart';
void main() {
group('ApiService', () {
late MockClient mockClient;
late ApiService apiService;
setUp(() {
mockClient = MockClient();
apiService = ApiService(mockClient);
});
test('fetchProducts retourne une liste de noms', () async {
// Arrange
when(mockClient.get(Uri.parse('https://api.example.com/products')))
.thenAnswer((_) async => http.Response(
'[{"name": "Product 1"}, {"name": "Product 2"}]',
200,
));
// Act
final products = await apiService.fetchProducts();
// Assert
expect(products, ['Product 1', 'Product 2']);
verify(mockClient.get(Uri.parse('https://api.example.com/products'))).called(1);
});
test('fetchProducts lance une exception si erreur', () async {
// Arrange
when(mockClient.get(Uri.parse('https://api.example.com/products')))
.thenAnswer((_) async => http.Response('Error', 404));
// Act & Assert
expect(
() => apiService.fetchProducts(),
throwsException,
);
});
});
}
Générer les mocks :
flutter pub run build_runner build
Tester les modèles
Modèle (lib/user.dart) :
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
};
}
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
email == other.email;
int get hashCode => id.hashCode ^ name.hashCode ^ email.hashCode;
}
Tests (test/user_test.dart) :
import 'package:flutter_test/flutter_test.dart';
import 'package:mon_projet/user.dart';
void main() {
group('User', () {
test('fromJson crée un User valide', () {
final json = {
'id': 1,
'name': 'John Doe',
'email': 'john@example.com',
};
final user = User.fromJson(json);
expect(user.id, 1);
expect(user.name, 'John Doe');
expect(user.email, 'john@example.com');
});
test('toJson retourne un Map valide', () {
final user = User(
id: 1,
name: 'John Doe',
email: 'john@example.com',
);
final json = user.toJson();
expect(json, {
'id': 1,
'name': 'John Doe',
'email': 'john@example.com',
});
});
test('Égalité de deux Users', () {
final user1 = User(id: 1, name: 'John', email: 'john@test.com');
final user2 = User(id: 1, name: 'John', email: 'john@test.com');
final user3 = User(id: 2, name: 'Jane', email: 'jane@test.com');
expect(user1, equals(user2));
expect(user1, isNot(equals(user3)));
});
});
}
Couverture de code
Générer un rapport de couverture :
flutter test --coverage
Visualiser avec genhtml (Linux/Mac) :
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
Ou avec VS Code extension "Coverage Gutters".
Bonnes pratiques
1. Suivre la règle AAA
test('Description', () {
// Arrange (Préparation)
final input = 'données';
// Act (Action)
final result = processData(input);
// Assert (Vérification)
expect(result, 'résultat attendu');
});
2. Un test = une assertion principale
// Mauvais
test('User validation', () {
expect(user.name, 'John');
expect(user.email, contains('@'));
expect(user.age, greaterThan(18));
});
// Bon
test('User name is correct', () {
expect(user.name, 'John');
});
test('User email is valid', () {
expect(user.email, contains('@'));
});
test('User is adult', () {
expect(user.age, greaterThan(18));
});
3. Noms de tests descriptifs
// Mauvais
test('test1', () { });
// Bon
test('fetchProducts returns list of products when API responds with 200', () { });
4. Tester les cas limites
group('Calculator divide', () {
test('divise deux nombres positifs', () { });
test('divise avec résultat négatif', () { });
test('divise par 1', () { });
test('divise 0 par un nombre', () { });
test('lance une erreur si division par zéro', () { });
});
5. Isoler les dépendances
Utilisez des mocks pour les dépendances externes (API, base de données, etc.)
6. Tests rapides
Les tests unitaires doivent s'exécuter en millisecondes, pas en secondes.
7. Tests indépendants
Chaque test doit pouvoir s'exécuter seul et dans n'importe quel ordre.
Tests de widgets
Les tests de widgets permettent de tester l'interface utilisateur de votre application Flutter. Ils vérifient que les widgets s'affichent correctement et réagissent aux interactions de l'utilisateur.
Exemple de base
Widget à tester (lib/counter_widget.dart) :
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
const CounterWidget({Key? key}) : super(key: key);
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
Widget build(BuildContext context) {
return Column(
children: [
Text('$_counter'),
ElevatedButton(
onPressed: _incrementCounter,
child: const Text('Incrémenter'),
),
],
);
}
}
Tests du widget (test/counter_widget_test.dart) :
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mon_projet/counter_widget.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Construit notre widget et déclenche une frame
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: CounterWidget(),
),
),
);
// Vérifie que le compteur commence à 0
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tape sur le bouton et déclenche une frame
await tester.tap(find.text('Incrémenter'));
await tester.pump();
// Vérifie que le compteur a été incrémenté
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
testWidgets('Trouve un widget par clé', (WidgetTester tester) async {
const testKey = Key('mon_widget');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Container(
key: testKey,
child: const Text('Hello'),
),
),
),
);
// Trouve le widget par sa clé
expect(find.byKey(testKey), findsOneWidget);
expect(find.text('Hello'), findsOneWidget);
});
testWidgets('Test avec icône', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: IconButton(
icon: const Icon(Icons.add),
onPressed: () {},
),
),
),
);
// Trouve le widget par son icône
expect(find.byIcon(Icons.add), findsOneWidget);
});
}
Éléments clés des widget tests
testWidgets()
Fonction qui crée un test de widget et fournit un WidgetTester.
WidgetTester
Objet pour interagir avec les widgets dans les tests :
// Construire et afficher le widget
await tester.pumpWidget(widget);
// Déclencher une frame après une interaction
await tester.pump();
// Attendre que toutes les animations soient terminées
await tester.pumpAndSettle();
// Simuler des interactions
await tester.tap(finder);
await tester.longPress(finder);
await tester.drag(finder, Offset(100, 0));
await tester.enterText(finder, 'texte');
Finders
Localisateurs pour trouver des widgets :
// Par texte
find.text('Mon texte');
// Par clé
find.byKey(Key('ma_cle'));
// Par type de widget
find.byType(ElevatedButton);
// Par icône
find.byIcon(Icons.add);
// Par widget (instance exacte)
find.byWidget(monWidget);
// Combinaisons
find.descendant(
of: find.byType(Container),
matching: find.text('Texte'),
);
Matchers
Vérifications pour les résultats de recherche :
// Trouve exactement un widget
expect(find.text('Hello'), findsOneWidget);
// Ne trouve aucun widget
expect(find.text('Goodbye'), findsNothing);
// Trouve au moins un widget
expect(find.byType(Text), findsWidgets);
// Trouve un nombre spécifique de widgets
expect(find.byType(ListTile), findsNWidgets(5));
Exemple avancé avec formulaire
testWidgets('Login form validation', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(),
),
),
);
// Trouve les champs de texte
final emailField = find.byKey(Key('email_field'));
final passwordField = find.byKey(Key('password_field'));
final submitButton = find.text('Se connecter');
// Entre du texte
await tester.enterText(emailField, 'test@example.com');
await tester.enterText(passwordField, 'password123');
await tester.pump();
// Vérifie que les valeurs sont correctes
expect(find.text('test@example.com'), findsOneWidget);
expect(find.text('password123'), findsOneWidget);
// Clique sur le bouton
await tester.tap(submitButton);
await tester.pumpAndSettle();
// Vérifie le résultat
expect(find.text('Connexion réussie'), findsOneWidget);
});
Tester les animations
testWidgets('Animation test', (WidgetTester tester) async {
await tester.pumpWidget(MyAnimatedWidget());
// État initial
expect(find.byType(FadeTransition), findsOneWidget);
// Déclenche l'animation
await tester.tap(find.text('Animer'));
// Avance l'animation de 200ms
await tester.pump(Duration(milliseconds: 200));
// Vérifie l'état intermédiaire
// ...
// Termine toutes les animations
await tester.pumpAndSettle();
// Vérifie l'état final
expect(find.text('Terminé'), findsOneWidget);
});
Points à retenir
- flutter_test : Package de test inclus par défaut
- test() : Définit un test unitaire
- expect() : Vérifie les résultats
- group() : Organise les tests
- setUp()/tearDown() : Prépare/nettoie avant/après chaque test
- Mocking : Simule les dépendances externes
- async/await : Pour tester le code asynchrone
- Couverture : Mesure la qualité des tests
- AAA : Arrange, Act, Assert
- Rapidité : Tests unitaires = rapides