Comment nous testons Sberbank Online sur iOS



Dans l' article précédent, nous nous sommes familiarisés avec la pyramide des tests et les avantages des tests automatisés. Mais la théorie est généralement différente de la pratique. Aujourd'hui, nous voulons parler de notre expérience dans le test de code d' application utilisé par des millions d'utilisateurs iOS. Et aussi sur le chemin difficile que notre équipe a dû parcourir pour obtenir un code stable.

La situation est la suivante: supposons que les développeurs aient réussi à se convaincre et à convaincre l'entreprise de la nécessité de couvrir la base de code avec des tests. Au fil du temps, le projet est devenu plus d'une douzaine de milliers de tests unitaires et plus d'un millier de tests d'interface utilisateur. Une base de test aussi importante a donné lieu à plusieurs problèmes, dont nous voulons savoir la solution.

Dans la première partie de l'article, nous nous familiariserons avec les difficultés qui se posent lors du travail avec des tests unitaires propres (sans intégration), dans la deuxième partie, nous considérerons les tests d'interface utilisateur. Pour découvrir comment nous améliorons la stabilité des essais, bienvenue sur Cat.

Dans un monde idéal, avec un code source inchangé, les tests unitaires devraient toujours montrer le même résultat, quel que soit le nombre et la séquence de démarrages. Et les tests en baisse constante ne doivent pas passer par la barrière du serveur d'intégration continue (CI).


En réalité, on peut rencontrer le fait que le même test unitaire montrera un résultat positif ou négatif - ce qui signifie «cligner des yeux». La raison de ce comportement réside dans la mauvaise implémentation du code de test. De plus, un tel test peut passer CI avec une exécution réussie, et plus tard il commencera à tomber sur la demande de tir (PR) d'autres personnes. Dans une situation similaire, il existe un souhait de désactiver ce test ou de jouer à la roulette et d'exécuter à nouveau le CI. Cependant, cette approche est anti-productive, car elle mine la crédibilité des tests et charge CI avec un travail insignifiant.

Ce problème a été souligné cette année lors de la conférence internationale d'Apple sur la WWDC:

  • Cette session aborde les tests parallèles, l'analyse de la couverture d'un code cible individuel avec des tests, ainsi que l'ordre de lancement des tests.
  • Ici, Apple a parlé de tester les requêtes réseau, le piratage, les notifications de test et la vitesse des tests.

Tests unitaires


Pour lutter contre les tests clignotants, nous utilisons la séquence d'actions suivante:

image

0. Nous évaluons le code de test de qualité selon des critères de base: isolement, justesse du moka, etc. Nous suivons la règle: avec un test clignotant, nous changeons le code de test, et non le code de test.

Si cet élément n'aide pas, procédez comme suit:

1. Nous fixons et reproduisons les conditions dans lesquelles le test tombe;
2. Trouvez la raison de la chute;
3. Modifiez le code de test ou le code de test;
4. Passez à la première étape et vérifiez si la cause de la chute a été éliminée.

Jouer à l'automne


L'option la plus simple et la plus évidente consiste à exécuter un test de problème sur la même version d'iOS et sur le même appareil. En règle générale, dans ce cas, le test est réussi et la pensée apparaît: «Tout fonctionne pour moi localement, je vais redémarrer l'assemblage sur CI». C'est juste que le problème n'a pas été résolu, et le test continue de tomber avec quelqu'un d'autre.

Par conséquent, à l'étape de vérification suivante, vous devez exécuter localement tous les tests unitaires de l'application pour identifier l'impact potentiel d'un test sur un autre. Mais même après une telle vérification, le résultat de votre test peut être positif, mais le problème n'est pas détecté.

Si la séquence de test entière a réussi et que la baisse attendue n'a pas été enregistrée, vous pouvez répéter l'analyse un nombre important de fois.
Pour ce faire, sur la ligne de commande, vous devez exécuter une boucle avec xcodebuild:

#! /bin/sh x=0 while [ $x -le 100 ]; do xcodebuild -configuration Debug -scheme "TargetScheme" -workspace App.wcworkspace -sdk iphonesimulator -destination "platfrom=iOS Simulator, OS=11.3, name=iPhone 7" test >> "report.txt"; x=$(( $x +1 )); done 

En règle générale, cela suffit pour reproduire la chute et passer à l'étape suivante - identifier la cause de la chute enregistrée.

Raisons de la chute et solutions possibles


Considérez les principales causes des tests unitaires clignotants que vous pouvez rencontrer dans votre travail, les outils pour les identifier et les solutions possibles.

Il y a trois principaux groupes de raisons pour la chute des tests:

Mauvaise isolation

Par isolement, nous entendons un cas particulier d'encapsulation, à savoir: un mécanisme de langage qui permet de restreindre l'accès de certains composants du programme à d'autres.

L'isolement de l'environnement joue un rôle important, car pour la pureté du test, rien ne doit affecter les entités testées. Une attention particulière doit être portée aux tests visant à vérifier le code. Ils utilisent des entités d'état globales, telles que: variables globales, trousseau, réseau, CoreData, Singleton, NSUserDefaults et ainsi de suite. C'est dans ces zones que le plus grand nombre de lieux potentiels de manifestation d'un mauvais isolement se présentent. Supposons que lors de la création d'un environnement de test, un état global soit défini, qui est implicitement utilisé dans un autre code de test. Dans ce cas, le test qui vérifie le code sous test peut commencer à «clignoter» - car selon la séquence de tests, deux situations peuvent survenir - lorsque l'état global est défini et lorsqu'il ne l'est pas. Souvent, les dépendances décrites sont implicites, vous pouvez donc accidentellement oublier de définir / réinitialiser ces états globaux.

Pour rendre les dépendances clairement visibles, vous pouvez utiliser le principe de l'injection de dépendance (DI), à savoir: passer la dépendance via les paramètres du constructeur ou la propriété de l'objet. Cela facilitera la substitution de fausses dépendances au lieu d'un objet réel.

Appel asynchrone

Tous les tests unitaires sont effectués de manière synchrone. La difficulté de tester l'asynchronie se pose parce que l'appel de la méthode de test dans le test «se fige» en prévision de l'achèvement de la portée du test unitaire. Le résultat sera une baisse stable du test.

 //act [self.testService loadImageFromUrl:@"www.google.ru" handler:^(UIImage * _Nullable image, NSError * _Nullable error) { //assert OCMVerify([cacheMock imageAtPath:OCMOCK_ANY]); OCMVerify([cacheMock dateOfFileAtPath:OCMOCK_ANY]); OCMVerify([imageMock new]); [imageMock stopMocking]; }]; [self waitInterval:0.2]; 

Pour tester un tel test, il existe plusieurs approches:

  1. Exécutez NSRunLoop
  2. waitForExpectationsWithTimeout

Les deux options vous obligent à spécifier un argument avec un délai d'expiration. Cependant, il ne peut être garanti que l'intervalle sélectionné sera suffisant. Localement, votre test passera, mais sur un CI lourdement chargé, il se peut qu'il n'y ait pas assez de puissance et il tombera - à partir de là, un «clignotement» apparaîtra.

Ayons une sorte de service de traitement des données. Nous voulons vérifier qu'après avoir reçu une réponse du serveur, il transfère ces données pour un traitement ultérieur.

Pour envoyer des demandes sur le réseau, le service utilise le client pour travailler avec lui.

Un tel test peut être écrit de manière asynchrone à l'aide d'un faux serveur pour garantir des réponses réseau stables.

 @interface Service : NSObject @property (nonatomic, strong) id<APIClient> apiClient; @end @protocol APIClient <NSObject> - (void)getDataWithCompletion:(void (^)(id responseJSONData))completion; @end - (void)testRequestAsync { // arrange __auto_type service = [Service new]; service.apiClient = [APIClient new]; XCTestExpectation *expectation = [self expectationWithDescription:@"Request"]; // act id receivedData = nil; [self.service receiveDataWithCompletion:^(id responseJSONData) { receivedData = responseJSONData; [expectation fulfill]; }]; [self waitForExpectationsWithTimeout:10 handler:^(NSError * _Nullable error) { expect(receivedData).notTo.beNil(); expect(error).to.beNil(); }]; } 

Mais la version synchrone du test sera plus stable et vous permettra de vous débarrasser du travail avec les timeouts.

Pour lui, nous avons besoin d'une maquette APIClient synchrone

 @interface APIClientMock : NSObject <APIClient> @end @implementation - (void)getDataWithCompletion:(void (^)(id responseJSONData))completion { __auto_type fakeData = @{ @"key" : @"value" }; if (completion != nil) { completion(fakeData); } } @end 

Ensuite, le test sera plus simple et fonctionnera plus stable

 - (void)testRequestSync { // arrange __auto_type service = [Service new]; service.apiClient = [APIClientMock new]; // act id receivedData = nil; [self.service receiveDataWithCompletion:^(id responseJSONData) { receivedData = responseJSONData; }]; expect(receivedData).notTo.beNil(); expect(error).to.beNil(); } 

Le fonctionnement asynchrone peut être isolé en encapsulant une entité distincte, qui peut être testée indépendamment. Le reste de la logique doit être testé de manière synchrone. Cette approche évitera la plupart des pièges apportés par l'asynchronie.

En option, dans le cas de la mise à jour de la couche d'interface utilisateur à partir du thread d'arrière-plan, vous pouvez vérifier si nous sommes dans le thread principal et ce qui se passera si nous appelons à partir du test:

 func performUIUpdate(using closure: @escaping () -> Void) { // If we are already on the main thread, execute the closure directly if Thread.isMainThread { closure() } else { DispatchQueue.main.async(execute: closure) } } 

Pour une explication détaillée, voir l'article de D. Sandell .

Test de code hors de votre contrôle
Souvent, nous oublions les choses suivantes:

  • la mise en œuvre des méthodes peut dépendre de la localisation de l'application,
  • il existe des méthodes privées dans le SDK qui peuvent être appelées par des classes de framework,
  • la mise en œuvre des méthodes peut dépendre de la version du SDK


Les cas ci-dessus introduisent une incertitude lors de l'écriture et de l'exécution des tests. Pour éviter des conséquences négatives, vous devez exécuter des tests sur tous les paramètres régionaux, ainsi que sur les versions d'iOS prises en charge par votre application. Séparément, il convient de noter qu'il n'est pas nécessaire de tester le code dont l'implémentation vous est cachée.

Avec cela, nous voulons compléter la première partie de l'article sur les tests automatisés de l'application iOS Sberbank Online, dédiée aux tests unitaires.

Dans la deuxième partie de l'article, nous parlerons des problèmes survenus lors de l'écriture de 1500 tests d'interface utilisateur, ainsi que des recettes pour les surmonter.

L'article a été écrit avec regno - Anton Vlasov, responsable du développement et développeur iOS.

Source: https://habr.com/ru/post/fr434320/


All Articles