
Сегодня вносимые изменения бережно проверяются вначале локально, а затем серией интеграционных тестов на сборочной ферме. В этой статье мы поговорим о первом этапе проверки — статическом тестировании, которое следит за корректностью ресурсов и анализирует код. Это первая подсистема в цепочке и на её долю приходится основная масса найденных ошибок.
С чего всё начиналось
Ручной процесс проверки игры перед релизом начинался в 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
<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 и кроссплатформенный, но у нас регулярно возникали проблемы с тем, что у пользователей своя уникальная среда, в которой может стоять не та версия, что мы ожидаем, несколько версий, либо вообще отсутствовать интерпретатор. Как итог — работает не так, как мы того ожидаем либо не работает вовсе. Было несколько итераций решения этой проблемы:
- Python и все пакеты устанавливает сам пользователь. Но есть два «но»: не все пользователи — программисты и установка через pip install для дизайнеров, да и для программистов тоже, может стать проблемой.
- Есть скрипт, который устанавливает все необходимые пакеты. Это уже лучше, но если у пользователя установлен не тот питон, то могут возникать коллизии в работе.
- Доставка нужной версии интерпретатора и зависимостей из хранилища артефактов (Nexus) и выполнение тестов в виртуальном окружении.
Другая проблема — быстродействие. Чем больше тестов, тем суммарно дольше происходит проверка изменений на компьютере пользователя. Раз в несколько месяцев идёт профилирование и оптимизация узких мест. Так был доработан контекст, появился кеш для текстовых файлов, доработаны механизмы предикатов (определения, что этот файл интересен тесту).
А дальше останется только решить проблему, как внедрить систему на все проекты и заставить всех разработчиков включить у себя прекоммитные хуки, но это уже совсем другая история…
Заключение
В процессе разработки мы потанцевали на граблях, больно бились, но всё же получили систему, которая позволяет находить ошибки во время коммита, сократила работу тестировщикам, а задачи перед релизом о пропаже текстуры остались в прошлом. Для полного счастья не хватает простой настройки окружения и оптимизации отдельных тестов, но над этим упорно трудятся големы из отдела ci.
Полный пример кода, используемого в статье в качестве примеров, можно посмотреть в нашем репозитории.