Юнит-тестирование часть 2 - Юнит-тестирование MonoBehaviour

TOMEK PASZEK Anonymous
Jun 3, 2014|10 Мин
Юнит-тестирование часть 2 - Юнит-тестирование MonoBehaviour
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

Как и было обещано в моей предыдущей записи в блоге Unit testing part 1 - Unit tests by the book, эта статья посвящена проектированию MonoBehaviour с учетом требований тестируемости. MonoBehaviour - это своего рода специальный класс, который обрабатывается Unity особым образом. Каждый раз, когда вы пытаетесь создать производную MonoBehaviour, вы получаете предупреждение о том, что это недопустимо. Будучи хорошим мальчиком-скаутом и не игнорируя предупреждение (игнорирование предупреждения вредно в долгосрочной перспективе!), вы могли бы задать себе вопрос, как же я могу высмеять MonoBehaviour? Хорошая новость заключается в том, что вам не придется этого делать! Позвольте представить вам...

Образец скромного объекта

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

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

Из состояния, когда тест имеет косвенную зависимость от нетестируемого компонента...

пример-зависимость1

...мы дошли до состояния, когда тест даже не знает о плохом (ну, просто нетестируемом) коде:

Вот, собственно, и все. Честно говоря, в этом нет ничего сложного.

Игры против тестируемости

Что делает игры такими особенными с точки зрения кода и тестируемости? Чем тестирование игр отличается от тестирования другого программного обеспечения? Лично я считаю игры довольно сложным программным обеспечением. Было бы наивно утверждать, что игры мало чем отличаются от программ, которыми вы пользуетесь каждый день. В играх (за исключением, конечно, исключений) вы найдете блестящую и отполированную графику, фоновую музыку и другие хорошо продуманные звуковые образцы. Игры часто нуждаются в обработке входных данных в реальном времени, потенциально из различных источников, а также в различных устройствах вывода (разрешение чтения). Нефункциональные требования также могут быть более строгими для игр. Multiplayer-игры потребуют от вас надежного синхронизированного сетевого соединения и в то же время высокой производительности, необходимой для поддержания постоянной частоты кадров.

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

Unity против тестируемости

Вся эта сложность имеет последствия для архитектуры кода. К нашему несчастью, высокопроизводительные архитектуры обычно работают против хорошего дизайна кода, и с этим ограничением вы можете столкнуться и в Unity. Один из основных механизмов, который пришлось разрабатывать особым образом, - это механизм MonoBehaviour. Если вы когда-нибудь задавались вопросом, почему обратные вызовы в MonoBehaviour не реализованы с помощью интерфейсов или наследования (как, возможно, подсказывает здравый смысл), то это сделано из соображений производительности (см. пояснение Лукаса Мейера в комментариях). Если не вдаваться в подробности, это также работает против тестируемости MonoBehaviour. Тот факт, что вы не можете инстанцировать MonoBehaviour с помощью оператора new, практически запрещает вам использовать любые фреймворки mocking. Вероятно, это было бы не очень хорошей идеей, учитывая все то, что происходит за сценой каждый раз, когда используется MonoBehaviour. Перехват такого поведения породит множество проблем.

Вы против тестируемости

В конечном итоге все зависит от вас и от того, насколько вы мотивированы писать тестируемый код. Многие подходы могут решить одну и ту же проблему, но только некоторые из них будут хорошо работать для автоматизации тестирования. Если вы хотите написать тестируемый код, иногда вам придется написать больше кода, чем вы считаете нужным. Если вы все еще учитесь (а разве мы не должны учиться всю жизнь?) или только вступили на путь автоматизации тестирования, некоторые фрагменты кода или проектные предположения могут показаться вам ненужным излишеством. Однако они так быстро входят в привычку, что вы даже не заметите, как начнете использовать про-автоматизированные конструкции, даже не задумываясь об этом.

В этой статье я обещал показать вам способ разработки MonoBehaviour для последующего тестирования. Это было не совсем верно, потому что мы не будем тестировать сами MonoBehaviour. Вероятно, вы уже имеете представление о том, как реализовать паттерн Humble Object Pattern в своем проекте, чтобы сделать его более тестируемым, но, тем не менее, позвольте мне показать вам эту идею, реализованную в реальном проекте.

Пример

Давайте создадим пример использования. Представьте себе простой контроллер игрока, который отвечает за управление космическим кораблем. Чтобы упростить пример, давайте поместим его в двумерное мировое пространство. Мы хотим, чтобы космический корабль мог летать во всех направлениях. У него есть пушка, которая может стрелять прямыми пулями (космическими ракетами?), но не чаще, чем заданный темп стрельбы. Количество пуль также ограничено емкостью патрона, поэтому, расстреляв их все, вам придется перезарядиться. Чтобы было интереснее, давайте сделаем так, чтобы скорость передвижения зависела от здоровья корабля.

MonoBehaviour, который будет служить контроллером для нашего космического корабля, может выглядеть следующим образом:

Изображение

В обратном вызове FixedUpdate мы считываем входные данные и выполняем действие в зависимости от того, какие кнопки были нажаты пользователем. Чтобы перемещаться по космическому кораблю, нам нужно переводить его положение со скоростью, постоянной в зависимости от направления осей. Как видно из кода, переменные deltaX и deltaY умножаются на: Time.fixedDeltaTime, значение с оси ввода и константа скорости, которая сама зависит от уровня здоровья.

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

Событие Fire2 просто перезарядит патронник.

Чтобы написать модульные тесты для этой логики, нам нужно решить две проблемы. Первый, как уже говорилось, - это немокабельный класс MonoBehaviour, от которого мы зависим через наследование. Вторая проблема является более общей для программ реального времени. Наша логика зависит от времени (скорости стрельбы), что делает невозможным проведение модульных тестов, поскольку мы не можем перехватить статический класс Time из Unity. Хорошая новость заключается в том, что все это можно решить.

Давайте немного рефакторим наш код, применив простую рефакторизацию извлечения методов и не забывая о том, что логические методы не должны ссылаться на API Unity (в данном случае обработка ввода и инстанцирование пули). Зависимость от времени в операторе if также должна быть вынесена в отдельный метод. Конечный результат должен выглядеть примерно так:

пример2

Как видите, метод FixedUpdate здесь не делает ничего, кроме как передает данные от пользователей методу, который выполняет логическую часть. Проверка скорости стрельбы была перенесена в метод CanFire, который генерирует результат "true", если прошло заданное количество времени. Это извлечение важно, так как оно позволит нам позже написать модульные тесты. Если бы мы могли поиздеваться над классом SpaceshipMotor прямо сейчас, мы бы просто перехватили метод CanFire и заставили его возвращать true или false, когда бы мы этого ни захотели. Это сделает тест независимым от времени. Но поскольку мы не можем высмеять SpaceshipMotor, так как он наследует от MonoBehaviour, нам нужно применить паттерн Humble Object Pattern.

Как нам это сделать? Нам просто нужно выделить весь логический код, который не использует Unity API, в отдельный класс и ввести ссылку на него в SpaceshipMotor. Давайте снова посмотрим на класс и выясним, что из него можно извлечь. TranformPosition и InstanciateBullet используют Unity API, но все остальное можно извлечь. Я знаю, что существует также статический класс Time, но к нему я вернусь позже.

Осталось объяснить, как извлеченная логика взаимодействует с API Unity, не завися от него. Именно здесь находятся интерфейсы. Класс с логикой будет содержать ссылку на интерфейс, и меня не будет волновать его фактическая реализация. Чтобы упростить ситуацию, мы можем реализовать эти интерфейсы непосредственно в самом MonoBehaviour! Давайте рассмотрим следующие 2 класса:

Пример3
Пример4

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

Unity не будет сериализовать ссылки на интерфейсы. Если вы не заботитесь о сериализации, просто передайте ссылки на интерфейс при создании класса SpaceshipController. В противном случае вы можете установить ссылки в обратном вызове OnEnable, который вызывается каждый раз после сериализации. Для справки, весь класс SpaceshipMotor будет сериализован обычным способом, только ссылки на интерфейс будут потеряны.

Вы наверняка заметили ссылку на класс Time в SpaceshipMotor. Я знаю, что говорил, что здесь не должно быть ссылок на Unity API, но я оставил их, чтобы продемонстрировать другой подход к работе с зависимостями, зависящими от времени. В идеале мы могли бы просто передавать значение Time.time в качестве аргумента методам.

Для любителей UML это конечный результат в виде (упрощенной) UML-диаграммы:

пример-умл1
Модульные тесты

С отсоединенным классом SpaceshipMotor ничто не мешает нам написать несколько модульных тестов. Взгляните на один из тестов:

Пример5

Тест подтверждает, что вы не можете стрелять, если у вас не осталось патронов. Сам тест построен по схеме Arrange-Act-Assert. В части, посвященной организации, мы создадим объектные макеты с помощью методов GetGunMock и GetControllerMock. GetControllerMock, помимо создания макета, переопределяет поведение метода CanFire, чтобы он всегда возвращал true. Это снимает зависимость от времени с объекта контроллера. Затем мы установим текущий номер пули на 0. После этого мы применяем fire к классу контроллера и проверяем, не был ли вызван Fire на интерфейсе контроллера оружия.

В проекте есть еще несколько тестов. Вы можете взять его отсюда и немного поиграть с ним. Я использовал NSubstitute в качестве объекта подражания. Мы также поставляем его версию с Unity Test Tools. Все три версии контроллера, о которых мы говорили здесь, вложены в проект.

На этом у меня сегодня все. Надеюсь, вам понравилось это чтение, и счастливого тестирования!

Томек