Статическое тестирование или спасти рядового Райана

    Релиз часто подкрадывается незаметно. И любая ошибка, внезапно обнаруженная перед ним, грозит нам сдвигом сроков, хотфиксами, работой до утра и потраченными нервами. Когда подобный аврал стал происходить систематически, мы поняли, что так больше жить нельзя. Было решено разработать систему всесторонней валидации, чтобы спасти рядового Райана разработчика Артёма, который перед релизом уходил домой в 9 вечера, или в 10, или в 11… ну вы поняли. Идея была в том, чтобы разработчик узнавал об ошибке, пока изменения еще не попали в репозиторий, а он сам не потерял контекста задачи.


    Сегодня вносимые изменения бережно проверяются вначале локально, а затем серией интеграционных тестов на сборочной ферме. В этой статье мы поговорим о первом этапе проверки — статическом тестировании, которое следит за корректностью ресурсов и анализирует код. Это первая подсистема в цепочке и на её долю приходится основная масса найденных ошибок.

    С чего всё начиналось


    Ручной процесс проверки игры перед релизом начинался в QA за неделю-полторы до выпуска. Естественно баги, которые находятся на этом этапе, необходимо исправлять в кратчайшие сроки.

    Из-за отсутствия времени на хорошее решение добавляется временный «костыль», который потом надолго приживается и обрастает другими не очень популярными решениями.

    Первым делом мы решили автоматизировать нахождение откровенных ошибок: падения, невозможность совершить основной для игры набор действий (открыть магазин, сделать покупку, поиграть в уровень). Для этого игра запускается в специальном режиме автоигры и, если что-то пошло не так, то мы об этом узнаем сразу после прохождения теста на нашей ферме.

    Но большинство ошибок, которые находили и тестировщики, и наш автоматизированный смоук-тест — отсутствие ресурса или некорректные настройки разных систем. Поэтому следующим шагом стало статическое тестирование — проверка наличия ресурсов, их взаимосвязей и настроек без запуска приложения. Запускалась эта система дополнительном шагом на сборочной ферме и значительно упростила нахождение и починку ошибок. Но зачем тратить ресурсы сборочной фермы, если можно обнаружить ошибку еще до совершения коммита и попадания проблемного кода в репозиторий? Сделать это можно прекомитными хуками, которые как раз запускаются перед созданием коммита и отправкой его в репозиторий.

    И да, мы настолько круты, что статическое тестирование перед коммитом и на сборочной ферме выполняется одним кодом, что значительно упрощает его поддержку.

    Наши усилия можно разделить на три направления:

    • создания сборочной фермы — того самого места, где будет собираться и проверяться всё то, что закоммитили;
    • разработка статических тестов — проверки корректности ресурсов, их взаимосвязей, запуск анализаторов кода;
    • разработка рантайм тестов — запуск приложения в режиме автоигры.

    Отдельной задачей была организация запуска тестов на машине у разработчика. Необходимо было минимизировать время выполнения локально (разработчик не должен ждать 10 минут, чтобы закоммитить одну строчку) и сделать так, чтобы у каждого вносящего изменения была установлена наша система.

    Много требований — одна система


    При разработке есть целый набор сборок, который может пригодиться: с читами и без, бета или альфа, iOS или Android. В каждом случае могут понадобиться разные ресурсы, настройки или даже разный код. Написание скриптов статических тестов под каждую возможную сборку выливается в запутанную систему с множеством параметров. Кроме того, что её сложно поддерживать, модифицировать, на каждом проекте есть ещё и свой набор костылей-велосипедов.

    Путём проб и ошибок мы пришли к одной системе, каждый тест в которой может учитывать контекст запуска и решать запускаться ему или нет, что именно и как проверять. У запуска тестов мы выделили три основных свойства:

    • тип сборки: для релизных и отладочных ресурсов проверки будут отличаться по строгости, полноте охвата, а так же настройками идентификаторов и проверкой доступного функционала;
    • платформа: то, что валидно для андроида, может быть некорректно для iOS, ресурсы также собираются по-разному и не все ресурсы в android-версии будут в iOS и наоборот;
    • место запуска: где именно запускаем — на сборочном агенте, где нужны все доступные тесты или же на компьютере у пользователя, где список запускаемого необходимо минимизировать.


    Система статических тестов


    Ядро системы и основной набор статических тестов реализованы на python. Основу составляет всего лишь несколько сущностей:


    Контекст тестирования — обширное понятие. В нём хранятся как параметры сборки и запуска, о которых мы говорили выше, так и мета-информация, которую заполняют и используют тесты.

    Для начала необходимо понять, какие тесты запускать. Для этого в мета-информации находятся типы интересующих нас ресурсов конкретно в этом запуске. Типы ресурсов определяются зарегистрированными в системе тестам. Тест можно «связать» с отдельным типом или несколькими и, если в момент коммита обнаружится, что изменились файлы, которые проверяет этот тест, значит изменился и связанный ресурс. Это удобно ложится в нашу идеологию — запускать локально как можно меньше проверок: если файлы, за которые отвечает тест, не изменились, то и запускать его не надо.

    К примеру, есть описание рыбки, в котором указана 3D-модель и текстура.Если файл с описанием изменился, значит проверяется, что указанная в нём модель и текстура существуют. В других случаях необходимости запуска проверки рыбки нет.

    С другой стороны, изменение ресурса может требовать изменений и зависящих от него сущностей: если изменился набор текстур, который хранится у нас в xml-файлах, то необходимо проверять дополнительно и 3Д-модели, так как может оказаться, что нужная модели текстура удалена. Оптимизации описанные выше применяются только локально на машине пользователя в момент коммита, а при запуске на сборочной ферме считается, что изменились все файлы и мы запускаем все тесты.

    Следующая проблема — зависимость одних тестов от других: нельзя выполнить проверку рыбки перед нахождением всех текстур и моделей. Поэтому мы разделили всё выполнение на две стадии:

    • подготовка контекста
    • выполнение проверок

    В первой стадии контекст заполняется информацией о найденных ресурсах (в случае с рыбкой — идентификаторами моделей и текстур). Во второй стадии, используя сохранённую информацию, просто проверить существует ли нужный ресурс. Упрощённо контекст представлен ниже.

    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]

    Определив все параметры, которые влияют на запуск теста, всю логику удалось спрятать внутри базового класса. В конкретном тесте остаётся написать только саму проверку и нужные значения для параметров.

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

    Возвращаясь к примеру с рыбкой, нужно два теста, один из которых находит текстуры и регистрирует их в контексте, другой ищет текстуры для найденных моделей.

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

    Распространение на проекты


    Разработка игр в Playrix происходит на собственном движке и, соответственно, все проекты имеют похожую файловую структуру и использующий одни правила код. Поэтому есть много общих тестов, которые пишутся один раз и находятся в общем коде. Проектам достаточно обновить версию системы тестирования и подключить к себе новый тест.

    Чтобы упростить интеграцию мы написали раннер, которому на вход подаётся конфигурационный файл и проектные тесты (о них позже). В конфигурационном файле содержится основная информация, о которой мы писали выше: тип сборки, платформу, путь к проекту.

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

    Прелесть конфиг-файла в том, что его можно сгенерировать на сборочной ферме для разных сборок в автоматическом режиме. Но передавать настройки для всех тестов через этот файл может быть не очень удобно. Для этого существует специальная настроечная xml, которая хранится в репозитории проекта и в ней прописываются списки игнорируемых файлов, маски для поиска в коде и так далее.

    Пример файла конфигурации
    [main]
    app_path = {app_path}
    build_type = production
    platform = ios

    Пример настроечной 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>
    

    Кроме общей части на проектах есть свои особенности и отличия, поэтому существуют наборы проектных тестов, которые подключаются к системе через конфигурирование раннера. Для кода в примерах для запуска будет достаточно пары строчек:

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

    Собранные грабли


    Хоть сам python и кроссплатформенный, но у нас регулярно возникали проблемы с тем, что у пользователей своя уникальная среда, в которой может стоять не та версия, что мы ожидаем, несколько версий, либо вообще отсутствовать интерпретатор. Как итог — работает не так, как мы того ожидаем либо не работает вовсе. Было несколько итераций решения этой проблемы:

    1. Python и все пакеты устанавливает сам пользователь. Но есть два «но»: не все пользователи — программисты и установка через pip install для дизайнеров, да и для программистов тоже, может стать проблемой.
    2. Есть скрипт, который устанавливает все необходимые пакеты. Это уже лучше, но если у пользователя установлен не тот питон, то могут возникать коллизии в работе.
    3. Доставка нужной версии интерпретатора и зависимостей из хранилища артефактов (Nexus) и выполнение тестов в виртуальном окружении.

    Другая проблема — быстродействие. Чем больше тестов, тем суммарно дольше происходит проверка изменений на компьютере пользователя. Раз в несколько месяцев идёт профилирование и оптимизация узких мест. Так был доработан контекст, появился кеш для текстовых файлов, доработаны механизмы предикатов (определения, что этот файл интересен тесту).

    А дальше останется только решить проблему, как внедрить систему на все проекты и заставить всех разработчиков включить у себя прекоммитные хуки, но это уже совсем другая история…

    Заключение


    В процессе разработки мы потанцевали на граблях, больно бились, но всё же получили систему, которая позволяет находить ошибки во время коммита, сократила работу тестировщикам, а задачи перед релизом о пропаже текстуры остались в прошлом. Для полного счастья не хватает простой настройки окружения и оптимизации отдельных тестов, но над этим упорно трудятся големы из отдела ci.

    Полный пример кода, используемого в статье в качестве примеров, можно посмотреть в нашем репозитории.
    • +23
    • 4,4k
    • 2
    Playrix
    56,24
    Разработчик мобильных free-to-play игр
    Поделиться публикацией

    Комментарии 2

      0
      Статические проверки настроек игры очень легко сделать и поддерживать и они очень помогают находить ошибки.

      Continious Integration для игровых проектов очень сложен в построении и поддержке. Для не супер больших компаний построение полноценного CI может быть не выгодна с финансовой точки зрения.
        0
        Для первых сборок может подойти и конфигурирование руками, но чем больше проект, тем сложнее охватить все возможные параметры.

        Сейчас довольно много решений, позволяющих настроить сборку и внедрить туда шаги тестирования. При распределённой команде это будет особенно удобно. Всё же если цель — выпустить проект в мир, то минимальный CI нужен (хотя бы сборка со статическими проверками раз в сутки). Так больше шансов словить косяк не перед дедлайном.
        А полноценный CI строится действительно очень долго и небольшими шагами, но жить сейчас без постоянных сборок, рапортующих «Аларма!» в случае сбоя, было бы грустно.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое