Tests unitaires dans Flutter : des éléments essentiels du flux de travail aux scénarios complexes

Publié: 2022-09-21

L'intérêt pour Flutter est à son plus haut niveau et il se fait attendre depuis longtemps. Le SDK open source de Google est compatible avec Android, iOS, macOS, Web, Windows et Linux. Une seule base de code Flutter les prend tous en charge. Et les tests unitaires sont essentiels pour fournir une application Flutter cohérente et fiable, garantissant contre les erreurs, les défauts et les défauts en améliorant de manière préventive la qualité du code avant qu'il ne soit assemblé.

Dans ce didacticiel, nous partageons des optimisations de flux de travail pour les tests unitaires Flutter, démontrons un test unitaire Flutter de base, puis passons à des cas de test et à des bibliothèques Flutter plus complexes.

Le déroulement des tests unitaires dans Flutter

Nous implémentons les tests unitaires dans Flutter de la même manière que nous le faisons dans d'autres piles technologiques :

  1. Évaluez le code.
  2. Configurez la simulation de données.
  3. Définissez le(s) groupe(s) de test.
  4. Définissez la ou les signatures de fonction de test pour chaque groupe de test.
  5. Rédigez les épreuves.

Pour illustrer les tests unitaires, j'ai préparé un exemple de projet Flutter et je vous encourage à utiliser et à tester le code à votre guise. Le projet utilise une API externe pour récupérer et afficher une liste d'universités que nous pouvons filtrer par pays.

Quelques notes sur le fonctionnement de Flutter : Le framework facilite les tests en chargeant automatiquement la bibliothèque flutter_test lors de la création d'un projet. La bibliothèque permet à Flutter de lire, d'exécuter et d'analyser des tests unitaires. Flutter crée également automatiquement le dossier de test dans lequel stocker les tests. Il est essentiel d'éviter de renommer et/ou de déplacer le dossier de test , car cela interrompt sa fonctionnalité et, par conséquent, notre capacité à exécuter des tests. Il est également essentiel d'inclure _test.dart dans nos noms de fichiers de test, car ce suffixe est la façon dont Flutter reconnaît les fichiers de test.

Tester la structure du répertoire

Pour promouvoir les tests unitaires dans notre projet, nous avons implémenté MVVM avec une architecture propre et une injection de dépendances (DI), comme en témoignent les noms choisis pour les sous-dossiers du code source. La combinaison des principes MVVM et DI assure une séparation des préoccupations :

  1. Chaque classe de projet prend en charge un seul objectif.
  2. Chaque fonction au sein d'une classe ne remplit que sa propre portée.

Nous créerons un espace de stockage organisé pour les fichiers de test que nous écrirons, un système où les groupes de tests auront des "maisons" facilement identifiables. À la lumière de l'exigence de Flutter de localiser les tests dans le dossier de test , reflétons la structure du dossier de notre code source sous test . Ensuite, lorsque nous écrivons un test, nous le stockons dans le sous-dossier approprié : tout comme les chaussettes propres vont dans le tiroir à chaussettes de votre commode et les chemises pliées dans le tiroir à chemises, les tests unitaires des classes Model vont dans un dossier nommé model , par exemple.

Structure de dossiers de fichiers avec deux dossiers de premier niveau : lib et test. Niché sous lib, nous avons le dossier de fonctionnalités, plus imbriqué est universités_feed, et plus imbriqué est data. Le dossier de données contient le référentiel et les dossiers source. Niché sous le dossier source se trouve le dossier réseau. Nichés sous le réseau se trouvent les dossiers du point de terminaison et du modèle, ainsi que le fichier university_remote_data_source.dart. Dans le dossier du modèle se trouve le fichier api_university_model.dart. Au même niveau que le dossier universités_feed mentionné précédemment se trouvent les dossiers de domaine et de présentation. Niché sous le domaine se trouve le dossier usecase. Nichés sous la présentation se trouvent les modèles et les dossiers d'écran. La structure du dossier de test mentionné précédemment imite celle de lib. Niché sous le dossier test se trouve le dossier unit_test qui contient le dossier universités_feed. Sa structure de dossiers est la même que celle du dossier universités_feed ci-dessus, avec ses fichiers fléchettes ayant "_test" ajouté à leurs noms.
La structure du dossier de test du projet reflétant la structure du code source

L'adoption de ce système de fichiers renforce la transparence du projet et offre à l'équipe un moyen simple de voir quelles parties de notre code sont associées à des tests.

Nous sommes maintenant prêts à mettre les tests unitaires en action.

Un test unitaire de flottement simple

Nous commencerons par les classes de model (dans la couche de data du code source) et limiterons notre exemple pour inclure une seule classe de model , ApiUniversityModel . Cette classe possède deux fonctions :

  • Initialisez notre modèle en vous moquant de l'objet JSON avec un Map .
  • Construire le modèle de données de l' University .

Pour tester chacune des fonctions du modèle, nous personnaliserons les étapes universelles décrites précédemment :

  1. Évaluez le code.
  2. Configurer le data mocking : Nous définirons la réponse du serveur à notre appel API.
  3. Définir les groupes de test : Nous aurons deux groupes de test, un pour chaque fonction.
  4. Définissez des signatures de fonction de test pour chaque groupe de test.
  5. Rédigez les épreuves.

Après avoir évalué notre code, nous sommes prêts à accomplir notre deuxième objectif : mettre en place un mocking de données spécifique aux deux fonctions au sein de la classe ApiUniversityModel .

Pour simuler la première fonction (initialisant notre modèle en simulant le JSON avec un Map ), fromJson , nous allons créer deux objets Map pour simuler les données d'entrée de la fonction. Nous allons également créer deux objets ApiUniversityModel équivalents pour représenter le résultat attendu de la fonction avec l'entrée fournie.

Pour simuler la deuxième fonction (construire le modèle de données University ), toDomain , nous allons créer deux objets University , qui sont le résultat attendu après avoir exécuté cette fonction dans les objets ApiUniversityModel précédemment instanciés :

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

Ensuite, pour nos troisième et quatrième objectifs, nous ajouterons un langage descriptif pour définir nos groupes de test et tester les signatures de fonction :

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

Nous avons défini les signatures de deux tests pour vérifier la fonction fromJson , et deux pour vérifier la fonction toDomain .

Pour remplir notre cinquième objectif et écrire les tests, utilisons la méthode expect de la bibliothèque flutter_test pour comparer les résultats des fonctions à nos attentes :

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

Après avoir atteint nos cinq objectifs, nous pouvons maintenant exécuter les tests, soit depuis l'IDE, soit depuis la ligne de commande.

Capture d'écran indiquant que cinq tests sur cinq ont réussi. L'en-tête indique : Exécuter : tests dans api_university_model_test.dart. Le panneau de gauche de l'écran indique : Résultats du test --- chargement de api_university_model_test.dart --- api_university_model_test.dart --- Tester l'initialisation d'ApiUniversityModel à partir de json --- Tester avec json un --- Tester avec json deux --- Tester ApiUniversityModel toDomain ---Tester toDomain en utilisant json one---Tester toDomain en utilisant json two. Le panneau de droite de l'écran indique : Tests réussis : cinq tests sur cinq --- test de flottement test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

Sur un terminal, nous pouvons exécuter tous les tests contenus dans le dossier de test en entrant la commande flutter test et voir que nos tests réussissent.

Alternativement, nous pourrions exécuter un seul test ou groupe de tests en entrant la flutter test --plain-name "ReplaceWithName" , en remplaçant le nom de notre test ou groupe de tests par ReplaceWithName .

Test unitaire d'un point de terminaison dans Flutter

Après avoir effectué un test simple sans dépendances, explorons un exemple plus intéressant : nous testerons la classe de point de endpoint , dont la portée englobe :

  • Exécution d'un appel API au serveur.
  • Transformer la réponse API JSON dans un format différent.

Après avoir évalué notre code, nous utiliserons la méthode setUp de la bibliothèque setUp pour initialiser les classes au sein de notre groupe de test :

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

Pour faire des requêtes réseau aux API, je préfère utiliser la bibliothèque retrofit, qui génère la plupart du code nécessaire. Pour tester correctement la classe UniversityEndpoint , nous allons forcer la bibliothèque dio, que Retrofit utilise pour exécuter les appels d'API, à renvoyer le résultat souhaité en se moquant du comportement de la classe Dio via un adaptateur de réponse personnalisé.

Intercepteur de réseau personnalisé

La moquerie est possible car nous avons construit la classe UniversityEndpoint via DI. (Si la classe UniversityEndpoint devait initialiser une classe Dio par elle-même, nous n'aurions aucun moyen de nous moquer du comportement de la classe.)

Afin de se moquer du comportement de la classe Dio , nous devons connaître les méthodes Dio utilisées dans la bibliothèque Retrofit , mais nous n'avons pas d'accès direct à Dio . Par conséquent, nous allons nous moquer de Dio en utilisant un intercepteur de réponse réseau personnalisé :

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

Maintenant que nous avons créé l'intercepteur pour simuler nos réponses réseau, nous pouvons définir nos groupes de test et tester les signatures de fonction.

Dans notre cas, nous n'avons qu'une seule fonction à tester ( getUniversitiesByCountry ), nous allons donc créer un seul groupe de test. Nous allons tester la réponse de notre fonction dans trois situations :

  1. La fonction de la classe Dio est-elle réellement appelée par getUniversitiesByCountry ?
  2. Si notre requête API renvoie une erreur, que se passe-t-il ?
  3. Si notre requête API renvoie le résultat attendu, que se passe-t-il ?

Voici nos signatures de groupe de test et de fonction de test :

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

Nous sommes prêts à écrire nos tests. Pour chaque cas de test, nous allons créer une instance de DioMockResponsesAdapter avec la configuration correspondante :

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

Maintenant que nos tests de point de terminaison sont terminés, testons notre classe de source de données, UniversityRemoteDataSource . Plus tôt, nous avons observé que la classe UniversityEndpoint fait partie du constructeur UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , ce qui indique que UniversityRemoteDataSource utilise la classe UniversityEndpoint pour remplir sa portée, c'est donc la classe que nous allons simuler.

Se moquer avec Mockito

Dans notre exemple précédent, nous nous sommes moqués manuellement de l'adaptateur de requête de notre client Dio à l'aide d'un NetworkInterceptor personnalisé. Ici, on se moque de toute une classe. Le faire manuellement - se moquer d'une classe et de ses fonctions - prendrait du temps. Heureusement, les bibliothèques fictives sont conçues pour gérer de telles situations et peuvent générer des classes fictives avec un minimum d'effort. Utilisons la bibliothèque mockito, la bibliothèque standard de l'industrie pour se moquer de Flutter.

Pour se moquer de Mockito , nous ajoutons d'abord l'annotation « @GenerateMocks([class_1,class_2,…]) » avant le code du test, juste au-dessus de la fonction void main() {} . Dans l'annotation, nous inclurons une liste de noms de classes en tant que paramètre (à la place de class_1,class_2… ).

Ensuite, nous exécutons la commande flutter pub run build_runner build de Flutter qui génère le code pour nos classes fictives dans le même répertoire que le test. Le nom du fichier fictif résultant sera une combinaison du nom du fichier de test plus .mocks.dart , remplaçant le suffixe .dart du test. Le contenu du fichier comprendra des classes fictives dont les noms commencent par le préfixe Mock . Par exemple, UniversityEndpoint devient MockUniversityEndpoint .

Maintenant, nous importons university_remote_data_source_test.dart.mocks.dart (notre fichier fictif) dans university_remote_data_source_test.dart (le fichier de test).

Ensuite, dans la fonction setUp , nous nous moquerons de UniversityEndpoint en utilisant MockUniversityEndpoint et en initialisant la classe UniversityRemoteDataSource :

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

Nous nous sommes moqués avec succès UniversityEndpoint , puis avons initialisé notre classe UniversityRemoteDataSource . Nous sommes maintenant prêts à définir nos groupes de test et tester les signatures de fonction :

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

Avec cela, nos moqueries, nos groupes de test et nos signatures de fonction de test sont configurés. Nous sommes prêts à écrire les tests réels.

Notre premier test vérifie si la fonction UniversityEndpoint est appelée lorsque la source de données lance la récupération des informations sur le pays. Nous commençons par définir comment chaque classe réagira lorsque ses fonctions seront appelées. Puisque nous nous sommes moqués de la classe UniversityEndpoint , c'est la classe avec laquelle nous allons travailler, en utilisant la structure de code when( function_that_will_be_called ).then( what_will_be_returned ) .

Les fonctions que nous testons sont asynchrones (fonctions qui renvoient un objet Future ), nous allons donc utiliser la structure de code when(function name).thenanswer( (_) {modified function result} ) pour modifier nos résultats.

Pour vérifier si la fonction getUniversitiesByCountry appelle la fonction getUniversitiesByCountry dans la classe UniversityEndpoint , nous utiliserons when(...).thenAnswer( (_) {...} ) pour simuler la fonction getUniversitiesByCountry dans la classe UniversityEndpoint :

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

Maintenant que nous nous sommes moqués de notre réponse, nous appelons la fonction de source de données et vérifions, à l'aide de la fonction de verify , si la fonction UniversityEndpoint a été appelée :

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

Nous pouvons utiliser les mêmes principes pour écrire des tests supplémentaires qui vérifient si notre fonction transforme correctement nos résultats de point de terminaison en flux de données pertinents :

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

Nous avons exécuté un certain nombre de tests unitaires Flutter et démontré différentes approches de moquerie. Je vous invite à continuer à utiliser mon exemple de projet Flutter pour effectuer des tests supplémentaires.

Tests unitaires Flutter : votre clé pour une expérience utilisateur supérieure

Si vous intégrez déjà des tests unitaires dans vos projets Flutter, cet article a peut-être introduit de nouvelles options que vous pourriez injecter dans votre flux de travail. Dans ce didacticiel, nous avons démontré à quel point il serait simple d'intégrer des tests unitaires dans votre prochain projet Flutter et comment relever les défis de scénarios de test plus nuancés. Vous ne voudrez peut-être plus jamais ignorer les tests unitaires dans Flutter.

L'équipe éditoriale du Toptal Engineering Blog exprime sa gratitude à Matija Becirevic et Paul Hoskins pour avoir révisé les exemples de code et autres contenus techniques présentés dans cet article.