Des tests statiques ou sauvez le soldat Ryan

Une version se glisse souvent inaperçue. Et toute erreur qui a été soudainement découverte devant lui nous menace d'un décalage des délais, des hotfix, du travail jusqu'au matin et des nerfs épuisés. Quand une telle ruée a commencé à se produire systématiquement, nous avons réalisé que vous ne pouvez plus vivre comme ça. Il a été décidé de développer un système de validation complet pour sauver le développeur ordinaire de Ryan , Artyom, qui est rentré chez lui avant 21 h, ou à 10 ou à 11 heures ... vous comprenez. L'idée était que le développeur découvre l'erreur alors que les modifications n'avaient pas encore atteint le référentiel, et lui-même n'a pas perdu le contexte de la tâche.


Aujourd'hui, les modifications apportées sont soigneusement vérifiées d'abord localement, puis avec une série de tests d'intégration sur la ferme d'assemblage. Dans cet article, nous parlerons de la première étape de la vérification - les tests statiques, qui surveillent l'exactitude des ressources et analysent le code. Il s'agit du premier sous-système de la chaîne et il représente la majeure partie des erreurs trouvées.

Comment tout a commencé


Le processus manuel de vérification du jeu avant la sortie a commencé dans QA une semaine et demie avant la sortie. Naturellement, les bogues qui sont à ce stade doivent être corrigés dès que possible.

En raison du manque de temps pour une bonne solution, une «béquille» temporaire est ajoutée, qui prend alors racine pendant longtemps et est entourée d'autres solutions peu populaires.

Tout d'abord, nous avons décidé d'automatiser la recherche d'erreurs apparentes: plantages, incapacité à terminer l'ensemble principal des actions du jeu (ouvrir un magasin, faire un achat, jouer à un niveau). Pour ce faire, le jeu démarre dans un mode de jeu automatique spécial et si quelque chose s'est mal passé, nous le saurons juste après avoir passé le test sur notre ferme.

Mais la plupart des erreurs que les testeurs et notre test de fumée automatisé ont constatées étaient le manque de ressources ou des paramètres incorrects de différents systèmes. Par conséquent, l'étape suivante consistait en des tests statiques - vérifier la disponibilité des ressources, leurs relations et leurs paramètres sans lancer l'application. Ce système a été lancé par une étape supplémentaire sur la ferme d'assemblage et a grandement simplifié la recherche et la correction des erreurs. Mais pourquoi gaspiller les ressources de la ferme d'assemblage si vous pouvez détecter une erreur avant même de valider et de placer le code du problème dans le référentiel? Cela peut être fait avec des hooks de pré-validation , qui sont juste démarrés avant que la validation ne soit créée et envoyée au référentiel.

Et oui, nous sommes tellement cool que les tests statiques avant de valider et sur la ferme d'assemblage sont effectués par un seul code, ce qui simplifie considérablement sa prise en charge.

Nos efforts peuvent être divisés en trois domaines:

  • création d'une ferme d'assemblage - l'endroit même où tout ce qui a été collecté et vérifié sera collecté;
  • développement de tests statiques - vérification de l'exactitude des ressources, de leurs relations, lancement d'analyseurs de code;
  • développement de tests d'exécution - lancement de l'application en mode lecture automatique.

Une tâche distincte consistait à organiser le lancement des tests sur la machine par le développeur. Il était nécessaire de minimiser le temps d'exécution localement (le développeur n'a pas à attendre 10 minutes pour valider une ligne) et de s'assurer que chaque système qui effectue les modifications a installé notre système.

De nombreuses exigences - un seul système


Pendant le développement, il y a tout un ensemble d'assemblages qui peuvent être utiles: avec et sans tricheurs, bêta ou alpha, iOS ou Android. Dans chaque cas, différentes ressources, paramètres ou même code différent peuvent être nécessaires. L'écriture de scripts pour des tests statiques pour chaque assemblage possible entraîne un système complexe avec de nombreux paramètres. En plus d'être difficile à entretenir et à modifier, chaque projet dispose également de son propre jeu de vélos à béquille.

Par essais et erreurs, nous sommes arrivés à un système, chaque test dans lequel peut prendre en compte le contexte de lancement et décider de l'exécuter ou non, quoi exactement et comment vérifier. Au début des tests, nous avons identifié trois propriétés principales:

  • type d'assemblage: pour les ressources de publication et de débogage, les contrôles seront différents en termes de rigueur, d'exhaustivité de la couverture, ainsi que de paramètres pour les identifiants et la vérification des fonctionnalités disponibles;
  • plate-forme: ce qui est valide pour Android peut être incorrect pour iOS, les ressources sont également collectées différemment et toutes les ressources de la version Android ne seront pas dans iOS et vice versa;
  • emplacement de lancement: où exactement allons-nous lancer - sur l'agent de build où tous les tests disponibles sont nécessaires ou sur l'ordinateur de l'utilisateur où la liste des startups doit être minimisée.


Système de test statique


Le cœur du système et l'ensemble de base des tests statiques sont implémentés en python. La base n'est que quelques entités:


Le contexte de test est un concept étendu. Il stocke à la fois les paramètres de construction et de lancement, dont nous avons parlé ci-dessus, ainsi que les méta-informations que les tests remplissent et utilisent.

Vous devez d'abord comprendre quels tests exécuter. Pour cela, la méta-information contient les types de ressources qui nous intéressent spécifiquement dans ce lancement. Les types de ressources sont déterminés par des tests enregistrés dans le système. Un test peut être «associé» à un ou plusieurs types, et si, au moment de la validation, il s'avère que les fichiers que ce test vérifie ont changé, alors la ressource associée a changé. Cela convient parfaitement à notre idéologie - pour exécuter localement le moins de vérifications possible: si les fichiers dont le test est responsable n'ont pas changé, vous n'avez pas besoin de l'exécuter.

Par exemple, il y a une description du poisson, dans laquelle le modèle 3D et la texture sont indiqués. Si le fichier de description a changé, il est vérifié que le modèle et la texture qui y sont indiqués existent. Dans d'autres cas, il n'est pas nécessaire d'effectuer une vérification du poisson.

D'un autre côté, changer une ressource peut nécessiter des changements et des entités en fonction: si l'ensemble des textures stockées dans nos fichiers xml a changé, alors il est nécessaire de vérifier des modèles 3D supplémentaires, car il peut s'avérer que la texture nécessaire au modèle est supprimée. Les optimisations décrites ci-dessus ne sont appliquées que localement sur la machine de l'utilisateur au moment de la validation, et lors du lancement sur la ferme d'assemblage, il est supposé que tous les fichiers ont changé et nous exécutons tous les tests.

Le problème suivant est la dépendance de certains tests par rapport à d'autres: il est impossible de vérifier le poisson avant de trouver toutes les textures et modèles. Par conséquent, nous avons divisé toute l'exécution en deux étapes:

  • préparation du contexte
  • vérification

Dans un premier temps, le contexte est rempli d'informations sur les ressources trouvées (dans le cas des poissons, avec des identifiants de motifs et de textures). Dans la deuxième étape, en utilisant les informations stockées, vérifiez simplement si la ressource souhaitée existe. Un contexte simplifié est présenté ci-dessous.

class VerificationContext(object):   def __init__(self, app_path, build_type, platform, changed_files=None):       self.__app_path = app_path       self.__build_type = build_type       self.__platform = platform       #          self.__modified_resources = set()       self.__expected_resources = set()       #      ,              self.__changed_files = changed_files       # -  ,          self.__resources = {} def expect_resources(self, resources):   self.__expected_resources.update(resources) def is_resource_expected(self, resource):   return resource in self.__expected_resources def register_resource(self, resource_type, resource_id, resource_data=None):   self.__resources.setdefault(resource_type, {})[resource_id] = resource_data def get_resource(self, resource_type, resource_id):   if resource_type not in self.__resources or resource_id not in self.__resources[resource_type]:       return None, None   return resource_id, self.__resources[resource_type][resource_id] 

Après avoir déterminé tous les paramètres qui affectent le lancement du test, nous avons réussi à cacher toute la logique à l'intérieur de la classe de base. Dans un test spécifique, il reste à écrire uniquement le test lui-même et les valeurs nécessaires pour les paramètres.

 class TestCase(object):  def __init__(self, name, context, build_types=None, platforms=None, predicate=None,               expected_resources=None, modified_resources=None):      self.__name = name      self.__context = context      self.__build_types = build_types      self.__platforms = platforms      self.__predicate = predicate      self.__expected_resources = expected_resources      self.__modified_resources = modified_resources      #               #   ,          self.__need_run = self.__check_run()      self.__need_resource_run = False  @property  def context(self):      return self.__context  def fail(self, message):      print('Fail: {}'.format(message))  def __check_run(self):      build_success = self.__build_types is None or self.__context.build_type in self.__build_types      platform_success = self.__platforms is None or self.__context.platform in self.__platforms      hook_success = build_success      if build_success and self.__context.is_build('hook') and self.__predicate:          hook_success = any(self.__predicate(changed_file) for changed_file in self.__context.changed_files)      return build_success and platform_success and hook_success  def __set_context_resources(self):      if not self.__need_run:          return      if self.__modified_resources:          self.__context.modify_resources(self.__modified_resources)      if self.__expected_resources:          self.__context.expect_resources(self.__expected_resources)   def init(self):      """        ,                    ,          """      self.__need_resource_run = self.__modified_resources and any(self.__context.is_resource_expected(resource) for resource in self.__modified_resources)  def _prepare_impl(self):      pass  def prepare(self):      if not self.__need_run and not self.__need_resource_run:          return      self._prepare_impl()  def _run_impl(self):      pass  def run(self):      if self.__need_run:          self._run_impl() 

Pour revenir à l'exemple des poissons, nous avons besoin de deux tests, dont l'un trouve les textures et les enregistre en contexte, l'autre recherche les textures pour les modèles trouvés.

 class VerifyTexture(TestCase):  def __init__(self, context):      super(VerifyTexture, self).__init__('VerifyTexture', context,                                          build_types=['production', 'hook'],                                          platforms=['windows', 'ios'],                                          expected_resources=None,                                          modified_resources=['Texture'],                                          predicate=lambda file_path: os.path.splitext(file_path)[1] == '.png')  def _prepare_impl(self):      texture_dir = os.path.join(self.context.app_path, 'resources', 'textures')      for root, dirs, files in os.walk(texture_dir):          for tex_file in files:              self.context.register_resource('Texture', tex_file) class VerifyModels(TestCase):  def __init__(self, context):      super(VerifyModels, self).__init__('VerifyModels', context,                                         expected_resources=['Texture'],                                         predicate=lambda file_path: os.path.splitext(file_path)[1] == '.obj')  def _run_impl(self):      models_descriptions = etree.parse(os.path.join(self.context.app_path, 'resources', 'models.xml'))      for model_xml in models_descriptions.findall('.//Model'):          texture_id = model_xml.get('texture')          texture = self.context.get_resource('Texture', texture_id)          if texture is None:              self.fail('Texture for model {} was not found: {}'.format(model_xml.get('id'), texture_id)) 

Répartition du projet


Le développement de jeux dans Playrix s'effectue sur son propre moteur et, par conséquent, tous les projets ont une structure de fichiers et un code similaires utilisant les mêmes règles. Par conséquent, de nombreux tests généraux sont écrits une seule fois et figurent dans le code général. Il suffit que les projets mettent à jour la version du système de test et connectent un nouveau test.

Pour simplifier l'intégration, nous avons écrit un runner qui reçoit le fichier de configuration et les tests de conception (à leur sujet plus tard). Le fichier de configuration contient des informations de base sur lesquelles nous avons écrit ci-dessus: type d'assemblage, plate-forme, chemin d'accès au projet.

 class Runner(object):  def __init__(self, config_str, setup_function):      self.__tests = []      config_parser = RawConfigParser()      config_parser.read_string(config_str)      app_path = config_parser.get('main', 'app_path')      build_type = config_parser.get('main', 'build_type')      platform = config_parser.get('main', 'platform')      '''      get_changed_files         CVS      '''      changed_files = None if build_type != 'hook' else get_changed_files()      self.__context = VerificationContext(app_path, build_type, platform, changed_files)      setup_function(self)  @property  def context(self):      return self.__context  def add_test(self, test):      self.__tests.append(test)  def run(self):      for test in self.__tests:          test.init()      for test in self.__tests:          test.prepare()      for test in self.__tests:          test.run() 

La beauté du fichier de configuration est qu'il peut être généré sur la ferme d'assemblage pour différents assemblages en mode automatique. Mais passer des paramètres pour tous les tests via ce fichier peut ne pas être très pratique. Pour ce faire, il existe un fichier de configuration spécial qui est stocké dans le référentiel du projet et des listes de fichiers ignorés, des masques de recherche dans le code, etc., y sont écrits.

Exemple de fichier de configuration
 [main] app_path = {app_path} build_type = production platform = ios 

Exemple d'optimisation de XML
 <root> <VerifySourceCodepage allow_utf8="true" allow_utf8Bom="false" autofix_path="ci/autofix"> <IgnoreFiles>*android/tmp/*</IgnoreFiles> </VerifySourceCodepage> <VerifyCodeStructures> <Checker name="NsStringConversion" /> <Checker name="LogConstructions" /> </VerifyCodeStructures> </root> 

En plus de la partie générale, les projets ont leurs propres particularités et différences; par conséquent, il existe des ensembles de tests de projet qui sont connectés au système via la configuration du runner. Pour le code des exemples, quelques lignes suffiront pour s'exécuter:

 def setup(runner):  runner.add_test(VerifyTexture(runner.context))  runner.add_test(VerifyModels(runner.context)) def run():  raw_config = '''  [main]  app_path = {app_path}  build_type = production  platform = ios  '''  runner = Runner(raw_config, setup)  runner.run() 

Râteau collecté


Bien que python lui-même soit multiplateforme, nous avons régulièrement eu des problèmes avec le fait que les utilisateurs ont leur propre environnement unique, dans lequel ils peuvent ne pas avoir la version que nous attendons, plusieurs versions, ou aucun interprète du tout. Par conséquent, cela ne fonctionne pas comme prévu ou ne fonctionne pas du tout. Il y a eu plusieurs itérations pour résoudre ce problème:

  1. Python et tous les packages sont installés par l'utilisateur. Mais il y a deux «mais»: tous les utilisateurs ne sont pas des programmeurs et l'installation via pip install pour les concepteurs, et pour les programmeurs aussi, peut être un problème.
  2. Il existe un script qui installe tous les packages nécessaires. C'est déjà mieux, mais si l'utilisateur a installé le mauvais python, des conflits peuvent survenir dans le travail.
  3. Fourniture de la version correcte de l'interpréteur et des dépendances du stockage d'artefacts (Nexus) et exécution de tests dans un environnement virtuel.

Un autre problème est la performance. Plus il y a de tests, plus la modification est vérifiée sur l'ordinateur de l'utilisateur. Tous les quelques mois, il y a un profilage et une optimisation des goulots d'étranglement. Le contexte a donc été amélioré, un cache pour les fichiers texte est apparu, les mécanismes des prédicats ont été améliorés (déterminant que ce fichier est intéressant pour le test).

Et puis il ne reste plus qu'à résoudre le problème de la façon d'implémenter le système sur tous les projets et de forcer tous les développeurs à inclure des hooks d'occasion, mais c'est une histoire complètement différente ...

Conclusion


Pendant le processus de développement, nous avons dansé sur le rake, nous nous sommes battus, mais nous avons tout de même un système qui nous permet de trouver des erreurs lors de la validation, de réduire le travail des testeurs et les tâches avant la publication concernant la texture manquante appartenaient au passé. Pour un bonheur complet, une simple configuration de l'environnement et l'optimisation des tests individuels ne suffisent pas, mais les golems du département ci y travaillent dur.

Un exemple complet du code utilisé comme exemples dans l'article peut être trouvé dans notre référentiel .

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


All Articles