Statische Tests oder Private Ryan speichern

Eine Veröffentlichung schleicht sich oft unbemerkt ein. Und jeder Fehler, der plötzlich vor ihm entdeckt wurde, droht uns mit einer Verschiebung der Fristen, Hotfixes, der Arbeit bis zum Morgen und der verbrauchten Nerven. Als ein solcher Ansturm systematisch begann, wurde uns klar, dass man so nicht mehr leben kann. Es wurde beschlossen, ein umfassendes Validierungssystem zu entwickeln, um den gewöhnlichen Ryan- Entwickler Artyom zu retten, der vor der Veröffentlichung um 21 Uhr, um 10 Uhr oder um 11 Uhr nach Hause ging. Der Entwickler sollte sich über den Fehler informieren, während die Änderungen das Repository noch nicht erreicht hatten und er selbst den Kontext der Aufgabe nicht verloren hatte.


Heute werden die vorgenommenen Änderungen zuerst lokal und dann mit einer Reihe von Integrationstests in der Assemblyfarm sorgfältig überprüft. In diesem Artikel werden wir über die erste Stufe der Überprüfung sprechen - statische Tests, die die Richtigkeit der Ressourcen überwachen und den Code analysieren. Dies ist das erste Subsystem in der Kette und macht den Großteil der gefundenen Fehler aus.

Wie alles begann


Der manuelle Prozess der Überprüfung des Spiels vor der Veröffentlichung begann anderthalb Wochen vor der Veröffentlichung in der Qualitätssicherung. Natürlich müssen Fehler, die sich in diesem Stadium befinden, so schnell wie möglich behoben werden.

Aufgrund des Zeitmangels für eine gute Lösung wird eine temporäre „Krücke“ hinzugefügt, die dann lange Wurzeln schlägt und von anderen nicht sehr beliebten Lösungen umgeben ist.

Zunächst haben wir uns entschlossen, das Auffinden offener Fehler zu automatisieren: Abstürze, Unfähigkeit, die wichtigsten Aktionen für das Spiel auszuführen (ein Geschäft eröffnen, einen Kauf tätigen, ein Level spielen). Zu diesem Zweck startet das Spiel in einem speziellen Autogame-Modus. Wenn etwas schief gelaufen ist, werden wir es sofort nach dem Bestehen des Tests auf unserer Farm erfahren.

Die meisten Fehler, die sowohl Tester als auch unser automatisierter Rauchtest feststellten, waren das Fehlen einer Ressource oder falsche Einstellungen verschiedener Systeme. Daher war der nächste Schritt das statische Testen - Überprüfen der Verfügbarkeit von Ressourcen, ihrer Beziehungen und Einstellungen, ohne die Anwendung zu starten. Dieses System wurde durch einen zusätzlichen Schritt in der Baugruppenfarm gestartet und vereinfachte das Auffinden und Beheben von Fehlern erheblich. Aber warum sollten Sie die Ressourcen der Assemblyfarm verschwenden, wenn Sie einen Fehler erkennen können, bevor Sie den Problemcode festschreiben und in das Repository übertragen? Dies kann mit Precommit-Hooks erfolgen , die gerade gestartet werden, bevor das Commit erstellt und an das Repository gesendet wird.

Und ja, wir sind so cool, dass statische Tests vor dem Festschreiben und in der Assemblyfarm von einem Code ausgeführt werden, was die Unterstützung erheblich vereinfacht.

Unsere Bemühungen können in drei Bereiche unterteilt werden:

  • Schaffung einer Montagefarm - genau dort, wo alles, was gesammelt und geprüft wurde, gesammelt wird;
  • Entwicklung statischer Tests - Überprüfung der Richtigkeit von Ressourcen, ihrer Beziehungen, Starten von Code-Analysatoren;
  • Entwicklung von Laufzeit-Tests - Starten der Anwendung im Auto-Play-Modus.

Eine separate Aufgabe bestand darin, den Start der Tests auf dem Computer durch den Entwickler zu organisieren. Es war notwendig, die Ausführungszeit lokal zu minimieren (der Entwickler muss nicht 10 Minuten warten, um eine Zeile festzuschreiben) und sicherzustellen, dass auf jedem System, das die Änderungen vornimmt, unser System installiert ist.

Viele Anforderungen - ein System


Während der Entwicklung gibt es eine ganze Reihe von Baugruppen, die sich als nützlich erweisen können: mit und ohne Cheats, Beta oder Alpha, iOS oder Android. In jedem Fall können unterschiedliche Ressourcen, Einstellungen oder sogar unterschiedlicher Code erforderlich sein. Das Schreiben von Skripten für statische Tests für jede mögliche Baugruppe führt zu einem komplizierten System mit vielen Parametern. Jedes Projekt ist nicht nur schwierig zu warten und zu modifizieren, sondern verfügt auch über einen eigenen Satz Krückenräder.

Durch Versuch und Irrtum kamen wir zu einem System, bei dem jeder Test den Startkontext berücksichtigen und entscheiden kann, ob er ausgeführt werden soll, was genau und wie zu überprüfen ist. Zu Beginn der Tests haben wir drei Haupteigenschaften identifiziert:

  • Art der Baugruppe: Bei der Freigabe und beim Debuggen von Ressourcen unterscheiden sich die Überprüfungen in Bezug auf Genauigkeit, Vollständigkeit der Abdeckung sowie Einstellungen für Kennungen und Überprüfung der verfügbaren Funktionalität.
  • Plattform: Was für Android gültig ist, kann für iOS falsch sein, Ressourcen werden auch anders gesammelt und nicht alle Ressourcen in der Android-Version werden in iOS sein und umgekehrt;
  • Startort: ​​Wo genau starten wir - auf dem Build-Agenten, auf dem alle verfügbaren Tests benötigt werden, oder auf dem Computer des Benutzers, auf dem die Liste der Starts minimiert werden muss.


Statisches Testsystem


Der Kern des Systems und die grundlegenden statischen Tests sind in Python implementiert. Die Basis sind nur wenige Entitäten:


Das Testen des Kontexts ist ein umfangreiches Konzept. Es speichert sowohl die Build- und Startparameter, über die wir oben gesprochen haben, als auch die Metainformationen, die die Tests füllen und verwenden.

Zuerst müssen Sie verstehen, welche Tests ausgeführt werden sollen. Zu diesem Zweck enthalten die Metainformationen die Arten von Ressourcen, an denen wir speziell für diesen Start interessiert sind. Ressourcentypen werden durch im System registrierte Tests bestimmt. Ein Test kann einem oder mehreren Typen zugeordnet werden. Wenn zum Zeitpunkt des Festschreibens festgestellt wird, dass sich die von diesem Test überprüften Dateien geändert haben, hat sich die zugeordnete Ressource geändert. Dies passt bequem zu unserer Ideologie - so wenige Prüfungen wie möglich lokal auszuführen: Wenn sich die Dateien, für die der Test verantwortlich ist, nicht geändert haben, müssen Sie sie nicht ausführen.

Beispielsweise gibt es eine Beschreibung des Fisches, in der das 3D-Modell und die Textur angegeben sind. Wenn sich die Beschreibungsdatei geändert hat, wird überprüft, ob das darin angegebene Modell und die Textur vorhanden sind. In anderen Fällen muss kein Fischcheck durchgeführt werden.

Andererseits kann das Ändern einer Ressource Änderungen und Entitäten erfordern, die davon abhängen: Wenn sich der Satz von Texturen, die wir in XML-Dateien speichern, geändert hat, müssen zusätzliche 3D-Modelle überprüft werden, da sich möglicherweise herausstellt, dass die benötigte Textur entfernt wird. Die oben beschriebenen Optimierungen werden zum Zeitpunkt des Festschreibens nur lokal auf dem Computer des Benutzers angewendet. Beim Start in der Assemblyfarm wird davon ausgegangen, dass sich alle Dateien geändert haben, und wir führen alle Tests aus.

Das nächste Problem ist die Abhängigkeit einiger Tests von anderen: Es ist unmöglich, den Fisch zu überprüfen, bevor alle Texturen und Modelle gefunden wurden. Daher haben wir die gesamte Ausführung in zwei Phasen unterteilt:

  • Kontextvorbereitung
  • Überprüfung

In der ersten Phase wird der Kontext mit Informationen über die gefundenen Ressourcen gefüllt (im Fall von Fischen mit Kennungen von Mustern und Texturen). Überprüfen Sie in der zweiten Phase anhand der gespeicherten Informationen einfach, ob die gewünschte Ressource vorhanden ist. Ein vereinfachter Kontext wird unten dargestellt.

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] 

Nachdem wir alle Parameter ermittelt hatten, die sich auf den Start des Tests auswirken, konnten wir die gesamte Logik in der Basisklasse ausblenden. In einem bestimmten Test müssen nur der Test selbst und die erforderlichen Werte für die Parameter geschrieben werden.

 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() 

Um auf das Fischbeispiel zurückzukommen, benötigen wir zwei Tests, von denen einer Texturen findet und im Kontext registriert, der andere nach Texturen für die gefundenen Modelle sucht.

 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)) 

Projektverbreitung


Die Spieleentwicklung in Playrix erfolgt auf einer eigenen Engine. Dementsprechend haben alle Projekte eine ähnliche Dateistruktur und einen ähnlichen Code nach denselben Regeln. Daher gibt es viele allgemeine Tests, die einmal geschrieben wurden und im allgemeinen Code enthalten sind. Für Projekte reicht es aus, die Version des Testsystems zu aktualisieren und einen neuen Test mit sich selbst zu verbinden.

Um die Integration zu vereinfachen, haben wir einen Runner geschrieben, der die Konfigurationsdatei und die Designtests (dazu später) erhält. Die Konfigurationsdatei enthält grundlegende Informationen, über die wir oben geschrieben haben: Art der Baugruppe, Plattform, Pfad zum Projekt.

 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() 

Das Schöne an der Konfigurationsdatei ist, dass sie in der Assemblyfarm für verschiedene Assemblys im automatischen Modus generiert werden kann. Das Übergeben von Einstellungen für alle Tests durch diese Datei ist jedoch möglicherweise nicht sehr praktisch. Zu diesem Zweck gibt es eine spezielle Konfigurations-XML, die im Projekt-Repository gespeichert ist und in die Listen ignorierter Dateien, Masken für die Suche im Code usw. geschrieben sind.

Beispiel für eine Konfigurationsdatei
 [main] app_path = {app_path} build_type = production platform = ios 

XML-Beispiel optimieren
 <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> 

Zusätzlich zum allgemeinen Teil weisen Projekte ihre eigenen Besonderheiten und Unterschiede auf. Daher gibt es eine Reihe von Projekttests, die über die Konfiguration des Läufers mit dem System verbunden sind. Für den Code in den Beispielen reichen ein paar Zeilen aus, um ausgeführt zu werden:

 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() 

Gesammelter Rechen


Obwohl Python selbst plattformübergreifend ist, hatten wir regelmäßig Probleme mit der Tatsache, dass Benutzer ihre eigene einzigartige Umgebung haben, in der sie möglicherweise nicht die erwartete Version, mehrere Versionen oder überhaupt keinen Interpreter haben. Infolgedessen funktioniert es nicht wie erwartet oder überhaupt nicht. Es gab mehrere Iterationen, um dieses Problem zu lösen:

  1. Python und alle Pakete werden vom Benutzer installiert. Es gibt jedoch zwei „Aber“: Nicht alle Benutzer sind Programmierer, und die Installation über Pip Install für Designer und auch für Programmierer kann ein Problem sein.
  2. Es gibt ein Skript, das alle erforderlichen Pakete installiert. Dies ist bereits besser, aber wenn der Benutzer die falsche Python installiert hat, können Konflikte in der Arbeit auftreten.
  3. Bereitstellung der richtigen Version des Interpreters und der Abhängigkeiten aus dem Artefaktspeicher (Nexus) und Ausführen von Tests in einer virtuellen Umgebung.

Ein weiteres Problem ist die Leistung. Je mehr Tests durchgeführt werden, desto länger wird die Änderung auf dem Computer des Benutzers überprüft. Alle paar Monate werden Engpässe profiliert und optimiert. So wurde der Kontext verbessert, ein Cache für Textdateien wurde angezeigt, Prädikatmechanismen wurden verbessert (um festzustellen, dass diese Datei für den Test interessant ist).

Und dann bleibt nur noch das Problem zu lösen, wie das System in allen Projekten implementiert und alle Entwickler gezwungen werden, gebrauchte Hooks einzuschließen, aber das ist eine ganz andere Geschichte ...

Fazit


Während des Entwicklungsprozesses haben wir auf dem Rechen getanzt, hart gekämpft, aber dennoch ein System erhalten, mit dem wir Fehler während des Festschreibens finden, die Arbeit der Tester reduzieren konnten und die Aufgaben vor der Veröffentlichung bezüglich der fehlenden Textur der Vergangenheit angehörten. Für ein vollkommenes Glück reichen eine einfache Einrichtung der Umgebung und die Optimierung einzelner Tests nicht aus, aber Golems aus der ci-Abteilung arbeiten hart daran.

Ein vollständiges Beispiel für den Code, der als Beispiele im Artikel verwendet wird, finden Sie in unserem Repository .

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


All Articles