Игры, тестирование и спецификации
Привет. Я Леша Науменко, позиция моя в Plarium Kharkiv называется Unity Software Architect, и сегодня я расскажу о своем опыте применения спецификационного тестирования при разработке игр.
Игры — это много всего сразу. В этой статье представим их как системы функций с кучей параметров, изменяющих некоторое общее состояние, чаще всего в реальном времени.
Более того, эти системы очень связаны между собой. Любому модулю вдруг может понадобиться знать о другом, который, на первый взгляд, никак к нему не относится. Характерным примером являются ачивки и квесты. Получить ачивку можно за что угодно, и, как следствие, система достижений хочет знать обо всем на свете.
Присутствие подобных связей, скрытых и не очень, затрудняет тестирование, так как изменение в одном модуле может затронуть несколько других по не слишком очевидным причинам. Хорошей практикой в таких случаях является создание автоматизированных тестов, фиксирующих нестабильное поведение. Вот мы и завели такую практику, и о подробностях этой затеи я вам сейчас расскажу.
Предпосылки, сложность и огромные числа
Сейчас в составе большой команды я работаю над мобильным RPG-проектом RAID: Shadow Legends, который вышел в релиз только в этом году. Примеры буду приводить из него. Механическая основа игры — коллекционирование и подбор героев для сражений против боссов в подземельях или других игроков на арене.
Правда, я тут не про саму игру, а про сложность ее тестирования хотел поговорить.
Давайте чуть-чуть понагнетаем, чтобы сложность ощущалась как следует. Каждый герой обладает несколькими характеристиками и набором скилов. При применении практически все скиллы могут оказывать несколько эффектов: урон, бафф, дебафф, хил, таунт. Ну вы знаете, как бывает в играх. В этот момент происходит изменение параметров и текущего состояния игры. К тому же эффекты взаимодействуют между собой, имеют шансы срабатывания и могут зависеть от других.
Битва пошаговая, одновременно в ней могут участвовать до пяти героев с каждой стороны, иногда даже больше (например, босс может призывать помощников).
У каждого героя по три-четыре собственных скилла плюс сеты артефактов, добавляющие одну-две пассивные способности, плюс таланты (тоже пассивки, по 15 на героя).
Время умножать числа! В одной битве может одновременно оказаться:
10 [герои] × ((3 [скилы] + 1 [артефакты] + 15 [таланты]) × 3 [эффекты в скиле]) = 10 × (19 × 3) = 570 эффектов
Героев пока что около 370, и раз в несколько месяцев добавляется по
способами.
Да, эта очевидная манипуляция с числами несколько искусственна. И да, далеко не каждый эффект взаимодействует с другими. Но даже с учетом всех нюансов получается действительно чудовищное и нечеловеческое количество комбинаций. Это создает огромное пространство для ошибок, что, к сожалению, подтверждается действительностью.
Написать код для этого — задача интересная и одновременно сложная (все, как любят программисты). Но ведь после написания кода следует тестирование. И после изменения кода следует тестирование. И даже после изменения визуальных эффектов следует тестирование.
И как-то так получается, что все это тестирование выполняется практически полностью вручную. Автоматизировать хочется, но часто только хочется. В причинах наблюдается некоторый парадокс: сжатые сроки, постоянно растущий за счет новых фич объем тестирования, нехватка свободных людей и возникающие временами ошибки на проде пока не позволяют выделить время на обучение автоматизации или программирование тестов, которые могли бы все эти факторы сократить и облегчить задачу.
Если у вас не так, напишите в комментариях, как построен процесс разработки, тестирования, что удалось автоматизировать и как это поддерживается.
Разработчики, QA и автоматизация
Со стороны разработки способом подстраховки и стабилизации поведения кода являются юнит-тесты. Ответ на вопрос об их полезности в каждой конкретной команде зависит от ее уровня и постановки этих самых тестов. Видел много команд, утонувших в связывании и наследовании юнит-тестов и в результате уверенных, что юнит-тесты замедляют разработку и усложняют процесс.
И даже в командах, где юнит-тесты в целом работают (например, в нашей), QA им полностью не доверяют — и правильно делают.
Вот был бы способ сделать так, чтобы тестировщики могли сами писать автоматизированные тесты, которым бы они доверяли и которые бы не требовали знаний программирования. Никаких.
Как насчет того, чтобы сделать сами требования тестами?
Спецификации, тесты и снова QA
Вычитка требований зачастую позволяет снизить уровень недопонимания между командами гейм-дизайна и разработки. Фиксация требований и уточнений в письменном виде — также один из инструментов уменьшения этого недопонимания. Но если бы все эти уточнения, мелочи и граничные случаи превращались в тесты, которые по-настоящему воссоздают игровую ситуацию и заставляют код работать в соответствии с ними, то все было бы еще лучше, не правда ли?
Ребятам из SpecFlow эта мысль понравилась настолько, что они сделали это возможным для .NET-среды и, как следствие, для Unity-геймдева. На самом деле не они это изобрели и, да-да, Cucumber и BDD существуют много лет, но впервые я познакомился с этим в геймдеве и именно со SpecFlow, поэтому похвалю их.
Познакомился благодаря господину TLK, с которым на тот момент работал. Именно ему пришла в голову отличная мысль, что спецификационное тестирование просто идеально подходит для игр.
Ведь правда: игровые ситуации зачастую описываются простым языком, понятным для всех, кто любит игры. Проблемы и баги с искусственным интеллектом или в боевой системе, как правило, описываются таким же языком, без утяжеленной терминологии.
Соответственно, и тесты хотелось бы описывать тем же простым и понятным языком: никаких классов, методов, менеджеров и дат. Только в этом случае QA будут их писать, поддерживать и доверять им. Получается ли так сделать? Давайте посмотрим на нашу попытку.
Код, паттерны и регулярность
Вот несколько примеров тестов, описывающих игровую ситуацию, — их называют сценариями.
Все в полном соответствии с каноном BDD:
- Given описывает расстановку;
- When — момент действия;
- Then — ожидаемый результат.
Перед нами текст на английском. Ломанном английском, да, но все еще без всяких «внутренностей» кода. И этот текст каким-то техномагическим образом проверяет, что мы не ошиблись, и отображается в привычном тестраннере! Разве не замечательно?
Маленький дисклеймер для опытных создателей BDD-сценариев и знатоков Gherkin: я понимаю, что это не принципиально новая штука, но все же некоторый восторг она у меня вызывает, потому что именно в такие моменты понимаешь, зачем учился программировать :-)
Разберем, что же происходит. Разумеется, текст преобразуется в код. Разумеется, с помощью регулярок, которые мы все очень любим. Но, правда, очень простых.
И все, исчезла магия, что ли? Ничего, смотрим дальше.
Каждой конструкции соответствует метод. Методы пишут уже программисты, в отличие от текста сценария, который может написать фактически кто угодно. Конструкции могут иметь синонимы — как в примере, где обе формулировки делают одно и то же. Это сделано для более комфортного написания сценариев разными людьми.
Каждый метод из блока Given наполняет расстановку, поэтому следует делать их маленькими и помнить об SRP. When просто дергает нужные ниточки, чтобы привести тестируемый механизм в действие. Then содержит проверки и assert-ы из вашего любимого фреймворка для этого. Мы используем fluentassertions.
Блок Given здесь необходимо рассмотреть более внимательно. Объект расстановки нужно наполнять постепенно, с каждым прочитанным предложением дополняя его чем-то новым. Чувствуете, да? Да это же паттерн Builder буквально в классическом представлении.
Да, именно он. И чтобы упростить себе жизнь, очень важно реализовать его здесь. Пример кода для построения битвы в блоке Given:
Есть еще один нюанс. Мы хотим больше человеческого языка и меньше всяких чисел и айдишников. По возможности все, что можно назвать, следует назвать с помощью enum-ов. Даже такие банальности, как Ally или Second [slot], будут выглядеть лучше, чем раскиданные по сценарию числа. И вместе с тем уменьшат возможность оступиться при написании самих тестов: вы ведь не одно число написали, а целое слово, которое невольно прочтешь — и быстрее заметишь неправильность.
Примеры про слот героя:
Код, проектирование и «надо было заранее»
Есть ли какие-то специфические требования к проекту и его коду, чтобы можно было реализовать все это великолепие?
Хотелось бы сказать, что нет, можно просто брать и тестировать, но, к сожалению, так не бывает. По крайней мере, без преодоления гор костылей.
О тестируемости вашего модуля придется подумать заранее. Можно и не совсем заблаговременно, но тогда нужно быть готовым к рефакторингам, что иногда тоже вполне себе опция.
Известная и много раз озвученная вещь, которую тем не менее следует отметить: для тестируемости нужно писать модульно, хорошо помня о DI и SRP. Будучи часто произносимой банальностью, это соображение так же часто игнорируется. Но в нашем случае это не просто для абстрактной красоты и гибкости, а для дела.
В примере модуль битвы написан как черный ящик, принимающий на входе setup битвы и отдающий наружу список результатов.
Это хорошо, и это разрешает нам создавать консольные интерфейсы для QA, прогонять миллионы битв параллельно (например, с целью обнаружения скрытых бесконечных рекурсий) и, собственно, неинвазивно тестировать.
Вам нужна будет эта модульность, поэтому продумайте ее заранее — или рефакторьте, это тоже бывает приятно и интересно.
Внедрение, поддержка — и, пожалуй, достаточно
Теперь, наконец, о сложном и наиболее важном — о внедрении в команду. Вот вы вдохновились (на что я надеюсь) и предложили руководству сделать фреймворк для спецификационного тестирования. Правильно расставили акценты и объяснили, почему это упростит всем жизнь в недалеком будущем. Прикинули, что для этого нужно сделать, дали приблизительный эстимейт и получили одобрение и время на работу.
И вот ваши тестовые тесты забегали и засветились зеленым. Вы с горящими глазами идете к QA-лиду сообщить, что у вас есть кое-что интересное для него. И после того как вы взахлеб расскажете о вашем новом техномагическом устройстве, вместо восторга, на который надеялись, вы можете встретить строгий взгляд, полный сомнения.
И на рассматриваемом этапе это будет справедливо. Это ведь вы знаете, что спецификации работают. Вы уверены в том, что они тестируют корректно и не являются ложноположительными или ложноотрицательными.
(Уверены ведь? :) На всякий случай вот цикл статей о том, как улучшить навык написания юнит-тестов. Те же подходы проверки устойчивости и качества тестов подойдут и для спецификаций.)
QA пока не доверяют им. Но вам нужно будет это исправить. Возьмите несколько особо неприятных для мануального тестирования тест-кейсов из их регрессионного чек-листа. Сделайте спецификационную версию этих тестов. После этого обязательно напишите несколько таких же тестов уже с QA. Проверьте тесты на ложность вместе с ними и сделайте так, чтобы результаты запуска тестов были у них перед глазами (CI и Slack-боты вам в помощь :-) Дайте им возможность пожить с этими тестами некоторое время.
Но недолго. Наведывайтесь, интересуйтесь, получайте фидбек и настаивайте на написании новых тестов. А лучше составьте план тестов, покрывающих определенный участок проблемных мест в проекте, и предложите его. В общем, работайте над внедрением и поддержкой. Это чуть ли не важнее, чем написать сам фреймворк, ведь если его никто не будет использовать, то какой смысл был создавать его? :-)
Наш опыт выглядел так. После создания фреймворка QA довольно быстро захотели избавиться от части мануальных проверок, которые связаны со взаимодействием эффектов и с некоторыми граничными случаями поведения AI.
Тестировщики решили писать спецификации втроем, по очереди меняясь несколько раз в неделю. При этом обсудили минимальные соглашения для формулировок, чтобы их не становилось бесконечно много, хотя синонимы допускались для удобства.
Например:
Писали они их вне зависимости от того, готова ли функциональность, транслирующая конкретные формулировки в код.
Вместе с тем мы выделили одного разработчика, который раз в неделю при наличии свободного времени реализовывал порцию недостающей для тестов функциональности. Таким образом нам удалось сделать этот процесс неутомительным и не особо замедляющим разработку.
Всего с тех пор написано 704 теста, которые постепенно и итеративно были реализованы, а теперь страхуют проект от случайных ошибок при каждом
Так что в целом освоить спецификационное тестирование у нас получилось.
Вот так и закончим. Буду рад ответить на вопросы и прочитать, как устроено автоматизированное тестирование у вас, особенно если вы делаете игры.
Спасибо, что дочитали. И хорошего чего у вас там за окном!