Workshop GN Mobile Monitoring - Tests en Flutter
Guide complet des tests unitaires et d’intégration
Introduction
Ce guide détaille les pratiques de tests pour le développement mobile Flutter dans le contexte de l’application GN Mobile Monitoring. Il fait suite au workshop principal.
🔧 Prérequis
Avant de commencer ce tutoriel sur les tests, assurez-vous d’avoir :
Suivi le workshop principal
Compris l’architecture Clean Architecture
Un environnement Flutter fonctionnel
Le projet GN Mobile Monitoring installé
Pourquoi ce guide séparé ?
Les tests sont un sujet complexe qui mérite un traitement approfondi. Ce guide dédié permet :
📚 Une exploration détaillée des stratégies de test
🎯 Des exemples concrets adaptés au projet GN Mobile Monitoring
🔧 Des patterns réutilisables pour vos propres tests
📈 Une progression pédagogique adaptée
Tests unitaires en Flutter
🎯 Pourquoi les tests unitaires ?
Dans Clean Architecture, les tests unitaires sont essentiels pour valider que votre logique métier fonctionne correctement, indépendamment de l’interface utilisateur ou des services externes.
Introduction à la pyramide des tests
Pyramide des tests : Tests unitaires (base large) → Tests d’intégration (milieu) → Tests E2E (sommet)
🧪 Tests unitaires
70% de vos tests
Rapides (< 1 seconde)
Isolés (pas de dépendances)
Testent la logique métier
Domain Layer seulement
🔗 Tests d’intégration
20% de vos tests
Plus lents (quelques secondes)
Testent les interactions
API + Database + Cache
Data Layer principalement
📱 Tests E2E
10% de vos tests
Très lents (minutes)
Interface complète
Parcours utilisateur
Toute l’application
Que tester avec les tests unitaires ?
✅ À TESTER (Domain Layer)
Use Cases : La logique métier pure
Validation des observations (dates, coordonnées)
Calculs de distances ou surfaces
Règles de compatibilité entre modules
Transformation et filtrage des données
Validation des protocoles de monitoring
Règles de synchronisation (conflits, merge)
Calculs GPS (surfaces, distances entre sites)
Formatage des nomenclatures
Validation des formulaires dynamiques
Logic de cache offline
Modèles : Les objets métier
Sérialisation/désérialisation JSON
Méthodes copyWith() générées par Freezed
Égalité et hashCode
Getters calculés et validation
Value Objects : Objets métier immutables
Coordonnées GPS, Email, ID utilisateur
Validation à la création
Comportements métier spécifiques
❌ À NE PAS TESTER (en unitaire)
Évitez de tester :
Widgets Flutter (→ Tests de widgets séparés)
Appels API HTTP (→ Tests d’intégration)
Base de données SQLite (→ Tests d’intégration)
Navigation entre écrans (→ Tests E2E)
Providers Riverpod (→ Tests d’intégration)
Configuration des tests dans le projet
test/
├── unit/
│ ├── domain/
│ │ ├── model/
│ │ │ └── observation_test.dart
│ │ └── usecase/
│ │ └── validate_observation_test.dart
│ └── data/
│ └── mapper/
│ └── observation_mapper_test.dart
├── integration/
│ └── api/
│ └── sites_api_test.dart
└── test_helpers/
├── mock_providers.dart
└── test_data.dart
# Lancer tous les tests unitaires
make test-unit
# ou
flutter test test/unit/
# Lancer un test spécifique
flutter test test/unit/domain/usecase/validate_observation_test.dart
# Tests avec couverture de code
flutter test --coverage
# Tests en mode watch (relance automatique)
flutter test --watch
# Lancer les tests d'intégration (nécessite .env.test)
make test-integration
# Lancer TOUS les tests
make test-all
# Tests avec couverture
flutter test --coverage --exclude-tags=integration
Exemples de tests concrets
Issu de form_config_parser_test.darttest('should parse conditional fields correctly', () { // Arrange - Configuration JSON du module final configJson = { 'fields': [ {'name': 'nb_individuals', 'type': 'number', 'required': true}, {'name': 'sex', 'type': 'nomenclature', 'hidden': 'nb_individuals == 0'} ] }; // Act final config = FormConfigParser.parse(configJson); // Assert expect(config.shouldShowField('sex', {'nb_individuals': 0}), false); expect(config.shouldShowField('sex', {'nb_individuals': 5}), true); });Issu de sync_cache_manager_test.darttest('should handle sync conflicts correctly', () async { // Arrange - Données locales vs serveur final localVisit = Visit(id: 1, lastModified: yesterday); final serverVisit = Visit(id: 1, lastModified: today); // Act final conflict = syncManager.detectConflict(localVisit, serverVisit); // Assert expect(conflict.type, ConflictType.dataModified); expect(conflict.requiresUserChoice, true); });Test spécifique aux données de terraintest('should calculate site area from GPS points', () { // Arrange - Coordonnées d'un site final gpsPoints = [ GPSPoint(lat: 45.123, lng: 5.456), GPSPoint(lat: 45.125, lng: 5.459), GPSPoint(lat: 45.121, lng: 5.461), GPSPoint(lat: 45.123, lng: 5.456), // Fermeture du polygone ]; // Act final area = SiteCalculator.calculateArea(gpsPoints); // Assert expect(area, closeTo(0.084, 0.01)); // ~840m² ± 10m² });
Objectif : Tester la logique métier de façon isolée, sans dépendances externes.
1import 'package:flutter_test/flutter_test.dart';
2import 'package:mockito/mockito.dart';
3
4void main() {
5 group('ValidateObservationUseCase', () {
6 late ValidateObservationUseCase useCase;
7
8 setUp(() {
9 useCase = ValidateObservationUseCase();
10 });
11
12 test('should reject future date', () async {
13 // Arrange
14 final futureObs = Observation(
15 id: '1',
16 date: DateTime.now().add(Duration(days: 1)), // ← Date future
17 latitude: 45.0,
18 longitude: 5.0,
19 );
20
21 // Act
22 final result = await useCase.call(futureObs);
23
24 // Assert
25 expect(result.isValid, false);
26 expect(result.error, contains('Date future'));
27 });
28
29 test('should reject missing coordinates', () async {
30 // Arrange
31 final invalidObs = Observation(
32 id: '2',
33 date: DateTime.now(),
34 latitude: null, // ← Coordonnées manquantes
35 longitude: null,
36 );
37
38 // Act
39 final result = await useCase.call(invalidObs);
40
41 // Assert
42 expect(result.isValid, false);
43 expect(result.error, contains('Coordonnées'));
44 });
45
46 test('should accept valid observation', () async {
47 // Arrange
48 final validObs = Observation(
49 id: '3',
50 date: DateTime.now(),
51 latitude: 45.0,
52 longitude: 5.0,
53 );
54
55 // Act
56 final result = await useCase.call(validObs);
57
58 // Assert
59 expect(result.isValid, true);
60 expect(result.error, isNull);
61 });
62 });
63}
1import 'dart:convert';
2import 'package:flutter_test/flutter_test.dart';
3
4void main() {
5 group('Observation Model', () {
6 const testObservation = Observation(
7 id: '123',
8 date: '2023-12-01T10:30:00Z',
9 species: 'Salamandre tachetée',
10 latitude: 45.123,
11 longitude: 5.456,
12 );
13
14 test('should serialize to JSON correctly', () {
15 // Act
16 final json = testObservation.toJson();
17
18 // Assert
19 expect(json['id'], '123');
20 expect(json['species'], 'Salamandre tachetée');
21 expect(json['latitude'], 45.123);
22 });
23
24 test('should deserialize from JSON correctly', () {
25 // Arrange
26 final jsonString = '''
27 {
28 "id": "456",
29 "date": "2023-12-02T14:15:00Z",
30 "species": "Triton palmé",
31 "latitude": 46.0,
32 "longitude": 6.0
33 }
34 ''';
35
36 // Act
37 final observation = Observation.fromJson(
38 jsonDecode(jsonString)
39 );
40
41 // Assert
42 expect(observation.id, '456');
43 expect(observation.species, 'Triton palmé');
44 expect(observation.latitude, 46.0);
45 });
46
47 test('copyWith should work correctly', () {
48 // Act
49 final modified = testObservation.copyWith(
50 species: 'Triton crêté',
51 latitude: 47.0,
52 );
53
54 // Assert
55 expect(modified.id, '123'); // ← Inchangé
56 expect(modified.species, 'Triton crêté'); // ← Modifié
57 expect(modified.latitude, 47.0); // ← Modifié
58 expect(modified.longitude, 5.456); // ← Inchangé
59 });
60
61 test('equality should work correctly', () {
62 // Arrange
63 const identical = Observation(
64 id: '123',
65 date: '2023-12-01T10:30:00Z',
66 species: 'Salamandre tachetée',
67 latitude: 45.123,
68 longitude: 5.456,
69 );
70
71 const different = Observation(
72 id: '999',
73 date: '2023-12-01T10:30:00Z',
74 species: 'Salamandre tachetée',
75 latitude: 45.123,
76 longitude: 5.456,
77 );
78
79 // Assert
80 expect(testObservation, equals(identical));
81 expect(testObservation, isNot(equals(different)));
82 });
83 });
84}
1import 'package:flutter_test/flutter_test.dart';
2import 'package:mockito/mockito.dart';
3import 'package:mockito/annotations.dart';
4
5// Génère automatiquement MockSitesRepository
6@GenerateMocks([SitesRepository])
7void main() {
8 late MockSitesRepository mockRepository;
9 late GetSitesUseCase useCase;
10
11 setUp(() {
12 mockRepository = MockSitesRepository();
13 useCase = GetSitesUseCase(mockRepository);
14 });
15
16 group('GetSitesUseCase', () {
17 test('should return sites from repository', () async {
18 // Arrange - Mock du comportement
19 final expectedSites = [
20 Site(id: 1, name: 'Site A', coordinates: [45.0, 5.0]),
21 Site(id: 2, name: 'Site B', coordinates: [46.0, 6.0]),
22 ];
23 when(mockRepository.getSites('POPAAMPHIBIEN'))
24 .thenAnswer((_) async => expectedSites);
25
26 // Act
27 final result = await useCase.call('POPAAMPHIBIEN');
28
29 // Assert
30 expect(result, expectedSites);
31 verify(mockRepository.getSites('POPAAMPHIBIEN')).called(1);
32 });
33
34 test('should handle repository error', () async {
35 // Arrange
36 when(mockRepository.getSites(any))
37 .thenThrow(Exception('Network error'));
38
39 // Act & Assert
40 expect(
41 () => useCase.call('POPAAMPHIBIEN'),
42 throwsA(isA<Exception>()),
43 );
44 });
45 });
46}
Bonnes pratiques pour les tests
📝 Pattern AAA
Arrange : Préparer les données et mocks
Act : Exécuter la fonction à tester
Assert : Vérifier le résultat attendu
test('should calculate distance correctly', () {
// Arrange ← 🔧 Préparation
final pointA = Coordinate(45.0, 5.0);
final pointB = Coordinate(45.1, 5.1);
// Act ← ⚡ Exécution
final distance = calculateDistance(pointA, pointB);
// Assert ← ✅ Vérification
expect(distance, closeTo(15.7, 0.1)); // ~15.7km ± 0.1
});
// ✅ BON - Décrit le comportement
test('should return empty list when no observations found')
test('should throw exception when user is not authenticated')
test('should validate email format correctly')
// ❌ MAUVAIS - Trop vague
test('test login')
test('check email')
test('repository test')
# Générer automatiquement les mocks
flutter packages pub run build_runner build
// Dans le fichier de test
@GenerateMocks([
SitesRepository,
AuthRepository,
DatabaseProvider,
])
# Générer un rapport de couverture
flutter test --coverage
# Voir le rapport dans un navigateur
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
Astuce
🎯 Objectif couverture : Visez 80%+ pour le Domain Layer, moins critique pour Presentation/Data.
test('debug example', () async {
// Afficher des valeurs pendant le test
print('Value: $result');
// Débugger avec le debugger
debugger(); // Pause ici si lancé avec --enable-vm-service
expect(result, isNotNull);
});
# Lancer les tests avec debug
flutter test --enable-vm-service test/unit/specific_test.dart
Intégration dans le workflow de développement
🔄 Cycle TDD
🔴 RED : Écrire un test qui échoue
🟢 GREEN : Écrire le minimum de code pour passer le test
🔵 REFACTOR : Améliorer le code tout en gardant les tests verts
// 1. RED - Test qui échoue
test('should calculate tax correctly', () {
final calculator = TaxCalculator();
expect(calculator.calculate(100), 20.0); // 20% TVA
}); // ← Classe TaxCalculator n'existe pas encore
// 2. GREEN - Code minimal qui marche
class TaxCalculator {
double calculate(double amount) => amount * 0.2;
}
// 3. REFACTOR - Améliorer sans casser
class TaxCalculator {
final double _taxRate;
TaxCalculator(this._taxRate);
double calculate(double amount) {
if (amount < 0) throw ArgumentError('Amount cannot be negative');
return amount * _taxRate;
}
}
# .git/hooks/pre-commit
#!/bin/sh
echo "Running unit tests..."
flutter test test/unit/ || exit 1
echo "All tests passed!"
Le projet utilise GitHub Actions pour l’automatisation des tests :
name: Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch: # Exécution manuelle
jobs:
# Tests unitaires (rapides, toujours exécutés)
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.3'
- run: flutter pub get
- run: flutter pub run build_runner build --delete-conflicting-outputs
- run: flutter analyze --no-fatal-infos
- run: flutter test --exclude-tags=integration --coverage
- uses: codecov/codecov-action@v3
# Tests d'intégration (avec serveur réel)
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: unit-tests
# Seulement sur main/develop ou exécution manuelle
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
- run: flutter pub get
- run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Run integration tests
env:
TEST_SERVER_URL: ${{ secrets.TEST_SERVER_URL }}
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
TEST_MODULES: ${{ secrets.TEST_MODULES }}
run: flutter test test/integration/ --tags=integration --reporter=expanded
Objectif : Tester la logique de présentation et gestion d’état avec Riverpod.
1import 'package:flutter_riverpod/flutter_riverpod.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:mocktail/mocktail.dart';
4
5void main() {
6 late ProviderContainer container;
7 late MockGetModulesUseCase mockGetModulesUseCase;
8
9 setUp(() {
10 mockGetModulesUseCase = MockGetModulesUseCase();
11 container = ProviderContainer(
12 overrides: [
13 getModulesUseCaseProvider.overrideWithValue(mockGetModulesUseCase),
14 ],
15 );
16 });
17
18 tearDown(() => container.dispose());
19
20 test('should load modules and update state correctly', () async {
21 // Arrange
22 final mockModules = [
23 const Module(
24 id: 1,
25 moduleCode: 'POPAAMPHIBIEN',
26 moduleLabel: 'Population Amphibiens',
27 downloaded: true,
28 ),
29 const Module(
30 id: 2,
31 moduleCode: 'POPREPTILE',
32 moduleLabel: 'Population Reptiles',
33 downloaded: false,
34 ),
35 ];
36
37 when(() => mockGetModulesUseCase.execute())
38 .thenAnswer((_) async => mockModules);
39
40 // Act
41 final viewModel = container.read(userModuleViewModelProvider.notifier);
42 await viewModel.loadModules();
43
44 // Assert
45 final state = container.read(userModuleViewModelProvider);
46 expect(state.data!.values.length, equals(2));
47 expect(state.data!.values[0].downloadStatus,
48 equals(ModuleDownloadStatus.moduleDownloaded));
49 expect(state.data!.values[1].downloadStatus,
50 equals(ModuleDownloadStatus.moduleNotDownloaded));
51 });
52}
Objectif : Tester l’interface utilisateur et les interactions.
1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:gn_mobile_monitoring/presentation/widgets/taxon_selector_widget.dart';
4
5void main() {
6 final testTaxons = [
7 Taxon(cdNom: 1, nomComplet: "Salamandra salamandra",
8 lbNom: "Salamandre tachetée", nomVern: "Salamandre"),
9 Taxon(cdNom: 2, nomComplet: "Triturus cristatus",
10 lbNom: "Triton crêté", nomVern: "Triton"),
11 ];
12
13 group('TaxonSelectorWidget Tests', () {
14 testWidgets('should show search placeholder initially', (tester) async {
15 // Arrange & Act
16 await tester.pumpWidget(MaterialApp(
17 home: Scaffold(
18 body: TaxonSelectorWidget(
19 moduleId: 123,
20 onChanged: (_) {},
21 label: 'Sélectionner un taxon',
22 ),
23 ),
24 ));
25
26 // Assert
27 expect(find.text('Rechercher un taxon...'), findsOneWidget);
28 expect(find.byIcon(Icons.search), findsOneWidget);
29 });
30
31 testWidgets('should display selected taxon', (tester) async {
32 // Arrange
33 await tester.pumpWidget(MaterialApp(
34 home: TaxonSelectorWidget(
35 moduleId: 123,
36 value: 1, // Taxon pré-sélectionné
37 onChanged: (_) {},
38 ),
39 ));
40
41 // Act
42 await tester.pumpAndSettle();
43
44 // Assert
45 expect(find.text('Salamandre (Salamandre tachetée)'), findsOneWidget);
46 expect(find.byIcon(Icons.clear), findsOneWidget);
47 });
48
49 testWidgets('should clear selection when clear button tapped', (tester) async {
50 // Arrange
51 await tester.pumpWidget(/* widget avec value: 1 */);
52 await tester.pumpAndSettle();
53
54 // Act
55 await tester.tap(find.byIcon(Icons.clear));
56 await tester.pumpAndSettle();
57
58 // Assert
59 final textField = tester.widget<TextFormField>(find.byType(TextFormField));
60 expect(textField.controller!.text, '');
61 expect(find.byIcon(Icons.search), findsOneWidget);
62 });
63 });
64}
Objectif : Tester les flux complets avec un serveur réel.
@Tags(['integration'])
void main() {
late TestServerConfig config;
setUpAll(() async {
// Charger configuration depuis .env.test
config = await TestEnvironmentSetup.getConfig();
});
test('should authenticate against real GeoNature server', () async {
// Arrange
final authRepo = AuthenticationRepositoryImpl();
// Act
final result = await authRepo.login(
email: config.username,
password: config.password,
);
// Assert
expect(result.isSuccess, true);
expect(result.token, isNotEmpty);
expect(result.user.email, equals(config.username));
});
test('should fetch modules after authentication', () async {
// Arrange
await AuthHelper.loginWithTestConfig(config);
final modulesRepo = ModulesRepositoryImpl();
// Act
final modules = await modulesRepo.getModules();
// Assert
expect(modules, isNotEmpty);
expect(modules.any((m) => m.moduleCode == 'POPAAMPHIBIEN'), true);
expect(modules.any((m) => m.moduleCode == 'POPREPTILE'), true);
});
}
# Serveur GeoNature de test
TEST_SERVER_URL=https://geonature-test.reservenaturelle.fr
TEST_USERNAME=antoine.schlegel
TEST_PASSWORD=antoine
TEST_MODULES=POPAAMPHIBIEN,POPREPTILE
# Tests unitaires uniquement
make test-unit
# ou
flutter test --exclude-tags=integration
# Tests d'intégration uniquement
make test-integration
# ou
flutter test test/integration/ --tags=integration
# Tous les tests
make test-all
# ou
flutter test
Configuration des tests d’intégration
# Serveur GeoNature de test
GEONATURE_API_URL=https://demo.geonature.fr
TEST_USERNAME=test@geonature.fr
TEST_PASSWORD=password
# Module de test
TEST_MODULE_CODE=POPAAMPHIBIEN
TEST_SITE_GROUP_ID=1
@Tags(['integration'])
void main() {
group('Sites API Integration', () {
// Tests nécessitant un serveur réel
});
}
Tests d’intégration spécifiques
🔧 Setup intégration
Avant de lancer les tests d’intégration :
Copiez
.env.test.examplevers.env.testConfigurez les credentials du serveur de test
Assurez-vous que le serveur GeoNature est accessible
Lancez :
make test-integration
@Tags(['integration'])
group('Authentication Integration', () {
test('should authenticate against real server', () async {
// Utilise les vraies API GeoNature
final authRepo = AuthenticationRepositoryImpl();
final result = await authRepo.login(
email: testConfig.username,
password: testConfig.password,
);
expect(result.isSuccess, true);
expect(result.token, isNotEmpty);
});
});
Métriques du projet GN Mobile Monitoring
📊 Tests actuels (novembre 2024)
Tests unitaires : ~80 fichiers de test
Tests d’intégration : 5 tests configurés avec serveur réel
Tests de widgets : 20+ widgets testés (formulaires, sélecteurs)
Tests de ViewModels : Riverpod providers avec mocks
Coverage Domain : ~85% (use cases bien testés)
Coverage Data : ~70% (repositories et mappers)
Coverage Presentation : ~60% (widgets et viewmodels)
Le projet suit cette organisation de tests :
test/
├── core/ # Tests utilitaires & helpers
│ └── helpers/ # 12 fichiers de test (parsers, formatters)
├── data/ # Tests couche Data (mocks)
│ ├── datasource/ # Tests API et Database
│ └── repository/ # 10+ repositories testés
├── domain/ # Tests couche Domain (unitaires)
│ ├── model/ # Tests modèles Freezed
│ ├── usecase/ # 40+ use cases testés
│ └── viewmodel/ # Tests ViewModels
├── presentation/ # Tests couche Presentation
│ ├── view/ # Tests pages et widgets
│ ├── viewmodel/ # Tests state management
│ └── widgets/ # Tests composants UI
├── integration/ # Tests d'intégration
│ ├── config/ # Configuration serveur test
│ ├── helpers/ # Helpers auth & données test
│ └── tests/ # Tests avec serveur réel
└── mocks/ # Mocks partagés
Les tests couvrent les fonctionnalités critiques :
- Authentification & Session
Login/logout avec serveur GeoNature
Gestion token JWT et persistance
Tests avec vraies API (tests d’intégration)
- Synchronisation de données
Upload observations/visites vers serveur
Download modules, sites, taxonomie
Gestion conflits et résolution automatique
- Formulaires dynamiques terrain
Parsing configuration JSON modules
Validation champs conditionnels (expressions JS)
Tests widgets nomenclatures et taxonomie
- Données géographiques
Calculs GPS (surfaces, distances)
Validation coordonnées et zones
Tests intégration cartes
- Tests avec serveur réel
Serveur : geonature-test.reservenaturelle.fr
Modules : POPAAMPHIBIEN, POPREPTILE
CI/CD GitHub Actions avec secrets
Ressources pour aller plus loin
Documentation officielle
Flutter Testing : https://docs.flutter.dev/testing
Mockito : https://pub.dev/packages/mockito
Integration Testing : https://docs.flutter.dev/testing/integration-tests
Coverage : https://docs.flutter.dev/testing/coverage
Ressources GeoNature
Repository GN Mobile : https://github.com/RNF-SI/gn_mobile_monitoring
Issues et discussions : https://github.com/RNF-SI/gn_mobile_monitoring/issues
Documentation API GeoNature : https://docs.geonature.fr/
Prochaines étapes
Après avoir maîtrisé les tests :
Contribuer au projet : Améliorer la couverture de tests
TDD en pratique : Développer une nouvelle feature en TDD
CI/CD : Configurer l’intégration continue sur vos projets
Tests E2E : Explorer les tests d’interface avec Flutter Driver
Retour au workshop principal