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

в 11:09, , рубрики: cтатическое тестирование, Gamedev, Playrix, python, Блог компании Playrix, Тестирование IT-систем, Тестирование игр

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

К примеру, есть описание рыбки, в котором указана 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.

Полный пример кода, используемого в статье в качестве примеров, можно посмотреть в нашем репозитории.

Автор: Иван Щербина

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js