В продолжение статьи о КОП я подробней расскажу о тестировании компонентов.
Существует много информации о юнит-тестировании и BDD, эта статья будет посвящена настройке SpecFlow для работы с Unity Engine, а также содержать общие рекомендации по созданию тестируемой архитектуры игры с компонентно-ориентированным подходом.
Общие правила разработки компонента, который можно будет легко протестировать, я сформулировал так:
1) То, что может видеть/слышать только человек, должно быть в отдельном компоненте.
2) У компонента должно быть «дефолтное» поведение. Лучше считать его эталонным.
3) Не забывайте: один компонент — одно поведение.
Таким образом, я внёс корректировки в свою реализацию core gameplay игры жанра Tower Defense:
Некоторые компоненты обращаются к Renderer через GetComponent() —это ненужная зависимость от обязательного присутствия компонента Renderer. Вся визуализация должна быть в отдельных компонентах. Указал дефолтные значения всем полям: это упростит и саму настройку компонента. Также расширил функционал игры, введя Игрока:
Игрок — человек, чьё поведение состоит в передаче управления в игру, посредством устройств ввода.
Теперь, для начала игры, надо нажать кнопку «Начать игру» в UI, после чего выбрать в UI башни для постройки с указанием куда их построить. И когда все башни будут построены, начнётся игра.
Но вот вопрос: для каких компонентов нужны Unit тесты, а какие лучше проверять по спецификации?
Ответ на этот вопрос может быть разным в зависимости от количества компонентов и общей сложности проекта. Стоит помнить, что общее поведение игрового объекта складывается из поведений компонентов. И рекомендация у меня такая:
Необходимо оценить сложность реализации логики внутри компонента. Если в ней легко допустить ошибку, стоит подстраховаться и сделать отдельный юнит-тест для компонента. В других случаях можно сделать общую спецификацию, которая будет проверять поведение игрового объекта в целом.
Подготовительная работа.
Из дополнений к Unity, нам понадобится следующее:
1) UnityTestTools — runner NUnit-тестов от разработчиков Unity в среде редактора.
2) Visual Studio Tools (бывший UnityVS) — плагин для отладки.
Для Visual Studio понадобится SpecFlow
И начнём мы с Unit-тестов.
Многие добавляют юнит-тесты непосредственно в сам проект, но не стоит утяжелять сборку лишним. Не так много времени займёт выделение набора тестов в отдельную сборку, которая не будет включена в финальную версию игры (build).
После добавления UnityTestTools в Unity, нужно открыть решение (сгенерированное UnityVs) и добавить в него новый проект типа «библиотека классов», в ней и будут наши юнит-тесты компонентов. Для примера, назовём сборку TowerDefenseCore.UnitTests. Важным моментом является настройка сборки:
1) В references сборки надо добавить:
— «nunit.framework.dll» и «nunit.core.dll» из AssetsUnityTestToolsUnitTestingEditorNUnitLibs
— «UnityEngine.dll» из LibraryUnityAssemblies
— Сборку «Assembly-CSharp.dll» из решения
Важно: не надо их копировать локально.
2) Необходимо настроить свойства сборки, указав путь вывода. Сборка должна быть в директории Assets — например, AssetsTests.
Написание юнит-тестов, исполняемых в Unity Test Runner, ничем не отличается от обычных юнит-тестов. Однако, есть несколько важных моментов.
Первое: делать инстанциирование игрового объекта (метод GameObject.Instantiate) не обязательно.
Вот простой тест компонента DamageApplicator:
[Test]
public void DamageApplicator_DirectDamage()
{
var damageApplicator = new DamageApplicator();
var hp = damageApplicator.DirectDamage(100, 10);
Assert.AreEqual(90f, hp);
}
DamageApplicator не имеет никаких зависимостей от других компонентов, которые разрешались бы в методе Awake/Start/OnEnable. Но если компонент использует Awake с этой целью, то его нужно вызывать принудительно:
[Test]
public void Hittable_DeadOnDirectDamage()
{
var componentsHolder = new GameObject();
//Добавляем компонент, от которого зависит тестируемый компонент
componentsHolder.AddComponent<DamageAplicator>();
var hittable = componentsHolder.AddComponent<Hittable>();
hittable.Awake();//Важно: мы не сделали инстанс объекта, потому вручную внедрим зависимость от DamageApplicator
hittable.DirectDamage(50);
Assert.AreEqual(50, hittable.HP);
hittable.DirectDamage(50);
Assert.AreEqual(true, hittable.IsDead);
}
Второе: проверка работы компонентов, использующих Coroutine. Тут важно указывать MaxTime, рассчитанное эталонное время выполнения (если оно есть) или в цикле while дополнительную проверку, пройден ли тест.
[Test]
[MaxTime(10000)]//BeginDPS наносит 5 единиц урона каждый 0.5 сек, у цели 100HP
public void DamageInflictor_BeginDPS()
{
var target = new GameObject();
target.AddComponent<DamageApplicator>();
var hittable = target.AddComponent<Hittable>();
hittable.Awake();
var tower = new GameObject();
var dmger = tower.AddComponent<DamageInflictor>();
dmger.BeginDPS(hittable);//Запускаем Coroutine
while (dmger.inflictDamage().MoveNext())//Симулируем работу Coroutine
Thread.Sleep(100);
Assert.True(hittable.IsDead);
}
Третий момент: лучше тестировать поведение каждого компонента, а не сцены в целом. В настройках Test Runner в опциях укажите Run test on a new scene.
Соберём сборку, переключимся на Unity и в меню Unity Test Tools выберем Test Runner. Как увидите, тесты из сборки появятся для запуска.
Настройка SpecFlow.
Создадим библиотеку классов TowerDefenseCore.Specs в решении. Настроим её:
1) Добавим в неё пакеты: Install-Package SpecFlow.NUnit
2) Добавим зависимости сборки:
— «TechTalk.SpecFlow.dll» из packagesSpecFlow.1.9.0libnet35
— «UnityEngine.dll» из LibraryUnityAssemblies
— Сборку «Assembly-CSharp.dll» из решения
Важно: не надо их копировать локально.
3) Необходимо настроить свойства сборки, указав путь вывода. Сборка должна быть в директории Assets — например AssetsTests.
4) Важно: скопируем «TechTalk.SpecFlow.dll» в AssetsUnityTestToolsUnitTestingEditorNUnitLibs.
Теперь в Test Runner помимо тестов из TowerDefenseCore.UnitTests будут и Steps из TowerDefenseCore.Specs.
Простой пример Feature, проверяющий поведение крипа при получении урона:
Feature: CreepLogic
Creep alive, take damage and dead.
@ creep
Scenario: Check creep is dead
Given Creep is alive
When Creep take damage 100
Then Creep is dead
И сгенерированные шаги:
[Binding]
public class CreepLogicSteps
{
private Hittable _creep;
[Given(@"Creep is alive")]
public void GivenCreepIsAlive()
{
var componentsHolder = new GameObject();
componentsHolder.AddComponent<DamageApplicator>();
_creep = componentsHolder.AddComponent<Hittable>();
_creep.Awake();
}
[When(@"Creep take damage (.*)")]
public void WhenCreepTakeDamage(int dmg)
{
_creep.DirectDamage(dmg);
}
[Then(@"Creep is dead")]
public void ThenCreepIsDead()
{
NUnitFramework.Assert.AreEqual(true, _creep.IsDead);
}
}
В Test Runner тест будет отображён с названием CheckCreepIsDead.
В начале статьи я упоминал про поведение Игрока. Какие есть способы автоматизировать проверку его поведения без участия человека — тема для отдельной статьи.
Автор: sountex