Всем привет, меня зовут Александр Мастюгин, я работаю тестировщиком в студии Nord. В сфере IT бытует предубеждение, что работа тестировщиком — нудное и однообразное занятие. Но я с этим не согласен: на мой взгляд, это творческая, техническая и исследовательская деятельность. Чтобы выполнять эту работу хорошо, нужно погрузиться в задачу, понять все ее тонкости, сложности, разобраться, какие у нее есть подводные камни.
Но для справедливости нужно сказать, что скучный момент все же есть — это регрессия. Чтобы минимизировать ее роль в рабочем процессе и, соответственно, избавиться от рутины, мы в студии Nord решили автоматизировать регрессионное тестирование. В этом тексте я расскажу, что у нас получилось.
Автоматизация механики автобатлера
Мы занимаемся разработкой игры Hustle Castle. Это мобильный автобатлер с элементами экономической стратегии и RPG. В качестве движка используется Unity, а сервер написан на Java. Core-механика автобоя заключается в следующем: юниты игрока и врага противостоят друг другу, у всех персонажей есть особая экипировка, которая дает способности, а сам бой идет автоматически — пользователь может лишь кастовать заклинания и использовать таланты своего героя.
Еще в Hustle Castle есть замок с кучей комнат — там можно добывать ресурсы, крафтить предметы и так далее. Также в игре есть сетевые механики с кланами, развитием территорий, ареной и многим другим. И все это сосуществует друг с другом и подчинено общей логике.
С описанием Hustle Castle закончили, теперь можно переходить к более глубоким вещам. Геймплей в игре завязан на способностях юнитов. С технической точки зрения абилка — это некая сущность, у которой есть множество настроек: как и когда она будет активироваться, на кого она будет действовать, какой у нее эффект, какие другие способности она может активировать и так далее.
Поведение каждой абилки мы описываем в виде объектов Json. Способности бывают простые, а есть структурные — в них возможна последовательная или параллельная активация других абилок. Кроме того, есть разные типы способностей, например, счетчики, баффы, станы. В общем, получается очень много всевозможных настроек.
Все это учитывается алгоритмом автобоя. Если сильно упростить, то работает это так. На входе есть некоторые данные — это состояние наших бойцов: какие у них статы, какими абилками они обладают. Эти данные мы передаем в батл-калькулятор, который и занимается всеми вычислениями. На каждый свой шаг вычислений он сообщает, какое произошло событие, активировал ли он какую-то абилку, нанес ли урон. После полного цикла вычислений мы получаем результат боя.
Стоит отметить, что код батл-калькулятора есть как на клиенте, так и на сервере. Это нужно для валидации результатов боя — получается, что и клиент, и сервер, и батл-калькулятор смотрят на одни и те же данные.
Чтобы автоматизировать проверку автобоя, мы решили сделать кастомный клиент, который умеет по входящим данным генерировать запрос к серверу на получение изначального стейта боя. Автотесты используют этот клиент, получают стейт, отдают его в калькулятор, подписываются на событие нового шага вычислений и дальше проверяют условия на каждом шаге.
Для проверок механик боевой системы мы используем абилки, собранные специально для тестов. Проверки нацелены на механики, абилки с прода мы не проверяем тестами.
Этот подход мы также используем в нашем внутреннем продукте под названием «Прогонялка боев». Она нужна нашим геймдизайнерам для баланса боевой системы. Геймдизайнер может взять любые стейты игроков с продакшена, объединить в тестовые группы, по желанию заменить одни абилки на другие и запустить прогонялку на большом количестве боев. По итогу прогона геймдизайнер получает статистику схваток.
Это был первый шаг к автоматизации. Наши автотесты дают уверенность, что базовая логика батл-калькулятора не сломалась после действий геймдизайнеров и разработчиков. Это комплексная вещь, для которой, к сожалению, у нас нет юнит-тестов. Поэтому эти автотесты боевого калькулятора — наша единственная возможность удостовериться, что большая часть боевой системы работает стабильно.
Тесты на сервере и на клиенте
Чтобы пояснить, что будет происходить дальше, обратимся к теории — перед вами известная пирамида тестирования.
Суть пирамиды проста. Чем мы ближе к основанию, тем дешевле и быстрее тесты. И наоборот — чем выше, тем дольше и дороже.
Решение вроде бы очевидное — нужно использовать юнит-тесты как самые быстрые и дешевые. Но все немного сложнее. Чтобы разработчики могли писать юнит-тесты, у приложения должен быть определенный дизайн — оно должно быть тестируемым. И, к сожалению, у нас это есть не везде.
Для серверной логики почти отсутствуют юнит-тесты, а те, что есть — это компонентные тесты, которые в основном покрывают матчмейкинг, базовую логику режимов и фичей.
Если брать отдельно сервер, то у нас следующая ситуация. Интеграционных тестов у нас на сервере нет. Тесты API теоретически можно написать, поскольку клиент и сервер у нас общаются по протоколу protobuf. А значит, описание протокола есть, можно взять клиент и слать запросы. Но пока что мы держим эту идею в запасе.
Что же на клиенте. Там дела обстоят несколько трагичнее. Юнит-тестов нет, компонентных тоже. Так мы оказываемся на вершине пирамиды — нам остается тестировать наше приложение через UI. Большая часть нашей игры выглядит вот так: много кнопок, диалогов, поп-апов, снова диалогов, снова кнопок. Почти все элементы интерфейса живут на Canvas.
В качестве базового инструмента мы взяли open-source решение AltUnityTester — это драйвер, который предоставляет:
Поиск объектов с помощью x-path;
Управление сценами;
Симуляцию input-методов (tap, scroll, drag-n-drop и так далее);
Вызов методов и получение свойств game-object’ов;
Протокол взаимодействия через web-сокет, который позволяет добавить множество других команд.
В итоге мы взяли Java, Allure, TestNG, решили применить паттерн Page-Object и начали писать тесты. Поначалу получалось очень здорово и классно. Мы написали примерно 10-15 базовых тестов, которые просто проходились по интерфейсу и что-то выполняли.
Однако очень быстро стало понятно, что наша кодовая база содержит ряд проблем, которые будут отзываться все сильнее и сильнее с ростом проекта. Первая была связана с селекторами. На скриншоте ниже приведен пример, как мы использовали Page-Object. Поля класса — селекторы, а методы содержали вызовы к драйверу и дополнительную логику.
Проблема заключалась не только в том, что это выглядело массивно, но еще и в том, что во все наши классы попало API AltUnity. И если разработчики в новой версии что-то поменяют, нам будет мучительно больно обновляться.
Другая проблема заключалась в ответственности Page-Object’ов. Во-первых, внутри Page-Object’а мы напрямую дергали драйвер (привет, API!). Во-вторых, объекты могли выполнять накрученную логику. В-третьих, наши Page-Object’ы знали о других Page-Object’ах — то есть навигацией по объектам занимались они сами.
Еще одной проблемой стала инъекция зависимостей. Когда классов было немного, все было в порядке. Но с усложнением тестов нужно было подключать кучу зависимостей, а также держать в голове, какие вообще есть.
Большое количество зависимостей вызывает ненужные трудности: например, если в компанию придет новый человек и попробует написать автотест — ему нужно будет изучить все многообразие API, составить ментальную карту того, какие есть у нас классы и как они связаны, и приложить еще много усилий, чтобы погрузиться в процесс.
И последняя проблема, с которой мы столкнулись — это дублирование кода. Например, на картинке выше представлен метод «OpenShopAndBuyRoom», который является приватным для этого тестового класса, поэтому мы не можем применять его где-то еще. Но так как мы стремимся написать больше тестов, мы хотим как-то переиспользовать этот метод и он должен принадлежать какому-то классу.
Время остановится и подумать
Использование AltUnityTester и паттерна Page-Object сильно напоминает автоматизацию в разработке web-приложений. Там наши коллеги используют Selenium WebDriver. И если взять концепции из web и переложить на нашу предметную область, то мы получим:
UnityDriver — взаимодействие с игрой.
Unity-Object — структурный паттерн для описания диалогов, экранов и сцен. Мы используем их только для описания структуры, а всей логикой занимаются степы.
Unity-Element — кнопки, картинки, диалоги, текст и так далее. В общем, все то, что есть на сцене в Unity, у нас — UnityElement.
Мы подсмотрели в исходники WebDriver и фреймворка HTML Elements и смогли адаптировать код под наши нужды. Также мы воспользовались паттерном Steps, чтобы отделить логику тестов от UnityObject'ов. На выходе мы получили фреймворк, с помощью которого мы можем:
Выделить сущности в отдельные классы (Button, Label, AbstractDialog и так далее).
Задавать x-path элементов UI с помощью аннотаций @FindBy, а также вводить новые аннотации и расширения.
Создавать отдельные блоки элементов и переиспользовать в разных диалогах за счет поиска объектов в контексте другого объекта.
Создавать представления компонентов в Unity на стороне тестов (так как на объекте может быть несколько компонентов).
За счет степов писать тесты в терминах бизнес-логики игры («Отрыть магазин», «Купить товар» и так далее).
Код AltUnity находится глубоко в ядре, а драйвер спрятан за интерфейсом.
Немного про степы — они соединяют наши тесты с Unity Objects. Как раз Unity Objects дают возможность кликнуть на элемент или передать какие-то данные из игры, а вся логика находится в степах. Это дает нам возможность писать тесты в терминах бизнес-процесса. Например, «На локации — открой казарму», «В казарме — проапгрейдь казарму», «Возьми юнита — перенеси его в казарму». А уже под капотом находится drag-n-drop, клики и все остальное.
Вторая особенность степов в том, что в дальнейшем их можно переиспользовать. И не только в рамках функциональных тестов. Например, недавно потребовалось реализовать прогон одного сценария на множестве разных стейтов игроков. Создали новый проект, подключили библиотеку со степами, несколько строчек кода для запуска сценария — задача выполнена.
Итак, ниже находится Unity Object. Помните, как выглядели наши селекторы? Они были страшно некрасивыми. Теперь же мы используем просто аннотации, в которых прописываем, как искать нужный элемент и все.
Так выглядит описание почти любого диалога у нас в проекте. При этом степам доступны кнопки с возможностью нажать на них, списки повторяющихся объектов, а также степы могут получить информацию со всего диалога (сколько у нас золота, какие слоты открыты или закрыты и так далее).
Инициализацией полей классов занимается Unity Element Loader — он получает определенный класс и драйвер. Согласно некоторой логике, мы создаем прокси-элементы для каждого поля в классе. И тем самым мы можем просто написать «Кнопка нажмись», хотя на самом деле система сначала найдет эту кнопку, информация об этом вернется обратно и только после этого будет отправлена команда «Нажать».
Ниже можно увидеть, что у нас есть степы для диалогового окна с квестами. И эти степы описываются уже в терминах самой игры.
Примерно так выглядят все тесты. Мы применяем лишь одну инъекцию самих степов. Отталкиваясь от нее, мы пишем в терминах бизнес-логики то, что хотим сделать. В итоге все выглядит достаточно аккуратно.
Планы на будущее
Наши основные планы — сделать еще больше тестов. Все эти усилия были направлены на удобное, простое и понятное расширение нашей кодовой базы. Но впереди у нас маячит проблема — многопоточность.
На данный момент тесты прогоняются в одном потоке для одного инстанса игры. Все работает хорошо, но это долго.
Чтобы справиться с этим, мы можем создавать несколько инстансов на нашем удаленном сервере. Или же можем собрать ферму из устройств и подключаться к ним. Но часть наших фич «Глобальны» и могут мешать прохождению тестов. Например, если открыт «Портал для фарма», то он открыт для всех. А при открытии или закрытии «Портала» есть нотификации, что могут появится в интерфейсе у параллельно идущего теста и тап произойдет случайно по нотификации, а не по нужному элементу.
Следующая вещь, которую мы хотели бы реализовать — это back-to-back тесты: это когда вы берете две версии приложения, запускаете один и тот же сценарий, и в определенный момент делаете скриншоты. А после сравниваете. Так вы можете проверить, не поехало ли что-то у вас, не появилась ли фича раньше времени и так далее.
На данный момент мы расширяем покрытие фич, у нас есть смоковый набор с хотя бы одним тестом на любой аспект игры, а также мы начали обучать коллег писать тесты.
Фреймворк для автоматизации тестирования — это такой же продукт. Он должен быть простым, понятным и легко расширяемым. При его проектировании не стоит забывать о паттернах и принципах разработки ПО, а также пренебрегать рефакторингом. Иначе спасение от регрессии станет вашей головной болью при сопровождении. Иногда стоит сделать шаг назад, чтобы завтра сделать три вперед.