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

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.dart
test('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.dart
test('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 terrain
test('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.

test/unit/domain/usecase/validate_observation_test.dart
 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}
test/unit/domain/model/observation_test.dart
 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}
test/unit/domain/usecase/get_sites_test.dart
 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

  1. 🔴 RED : Écrire un test qui échoue

  2. 🟢 GREEN : Écrire le minimum de code pour passer le test

  3. 🔵 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 :

.github/workflows/integration_tests.yml
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.

test/presentation/viewmodel/modules_utilisateur_viewmodel_test.dart
 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.

test/presentation/view/taxon_selector_widget_test.dart
 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.

test/integration/tests/01_auth_integration_test.dart
@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);
  });
}
.env.test (Configuration requise)
# 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

.env.test (copier depuis .env.test.example)
# 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 :

  1. Copiez .env.test.example vers .env.test

  2. Configurez les credentials du serveur de test

  3. Assurez-vous que le serveur GeoNature est accessible

  4. Lancez : make test-integration

test/integration/auth_integration_test.dart
@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

Ressources GeoNature

Prochaines étapes

Après avoir maîtrisé les tests :

  1. Contribuer au projet : Améliorer la couverture de tests

  2. TDD en pratique : Développer une nouvelle feature en TDD

  3. CI/CD : Configurer l’intégration continue sur vos projets

  4. Tests E2E : Explorer les tests d’interface avec Flutter Driver


Retour au workshop principal