По материалам курса K-Syndicate: Advanced Unit Testing In Unity https://lms.k-syndicate.school/unittesting/

В прошлом посте было показано, как добавить проверки в Unity, которые можно вызвать в ручном режиме.

Для работы с юнит тестами в Unity уже есть готовое окно. Оно открывается через: Window → General → Test Runner.

Unity позволяет работать с двумя типами тестов: Play Mode и Edit Mode.

Собственно, из названия должно быть понятно, какие тесты какими являются:

  • EditMode тесты запускаются, не запуская саму игру, и имеют доступ к Editor коду.
  • PlayMode тесты запускают игру и работают в ней (но на отдельной сцене). Тесты могут являться корутинами, что позволяет пропускать кадры.

Создадим папку с тестами через кнопку “Create Edit Mode Test Assembly Folder”. Это создаст папку, внутри которой уже сразу будет лежать asmdef файл для проекта с тестами.

Теперь создадим скрипт с тестами, для этого нажмем кнопку “Create Test Script in current folder”.

Для начала попробуем просто вызывать наш валидатор напрямую из тестов. У нас этого не выйдет, поскольку наша сборка с тестами не видит сборку с кодом. Если посмотреть, на какие сборки ссылаются тесты, то можно увидеть:

  • UnityEngine.TestRunner
  • UnityEditor.TestRunner
  • nunit.framework.dll

Нужно создать сборку для наших скриптов и добавить на нее ссылку в тесты.

Переместим наш валидатор в папку Tools. Внутри нее создадим новый Assembly Definition файл, который назовем Tools. Теперь сошлемся на него в asmdef файле тестов: Assembly Definition References → “+” → выбираем наш созданный Tools файл.

Теперь в коде тестов можно ссылаться на скрипты из папки Tools.

Теперь можем написать свой первый юнит тест:

[Test]
public void MissingComponentsValidation()
{
   Validator.FindMissingComponents();
}

На самом деле валидатор нам не нужен. Он нужен был только в рамках данного поста, чтобы показать, как сослаться на код из скриптов. Все, что он умеет можно делать сразу в тестах. Перенесем валидатор в тесты. Вот что получится:

public class GameObjectValidationTests
{
   [Test]
   public static void FindMissingPrefabs() =>
      ValidateGameObjectsOnScenes(
         PrefabUtility.IsPrefabAssetMissing,
         (scene, gameObject) => Debug.unityLogger.LogError("🧱", $"On scene {scene.name} game object {gameObject.name} has missing prefab"));

   [Test]
   public static void FindMissingComponents() =>
      ValidateGameObjectsOnScenes(
         go => GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(go) > 0,
         (scene, gameObject) => Debug.unityLogger.LogError("🔗", $"On scene {scene.name} game object {gameObject.name} has missing component"));

   private static void ValidateGameObjectsOnScenes(Func<GameObject, bool> validator, Action<Scene, GameObject> callback)
   {
      foreach (var scene in GetAllProjectScenesOpened())
      foreach (var gameObject in GetAllGameObjects(scene))
         if (validator(gameObject))
            callback(scene, gameObject);
   }

   private static IEnumerable<Scene> GetAllProjectScenesOpened()
   {
      var scenePaths = AssetDatabase
         .FindAssets("t:Scene", new[] {"Assets"})
         .Select(AssetDatabase.GUIDToAssetPath);

      foreach (var scenePath in scenePaths)
      {
         var currentScene = SceneManager.GetSceneByPath(scenePath);
         if (currentScene.isLoaded)
         {
            yield return currentScene;
         }
         else
         {
            var openedScene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Additive);
            yield return openedScene;
            EditorSceneManager.CloseScene(openedScene, true);
         }
      }
   }

   private static IEnumerable<GameObject> GetAllGameObjects(Scene scene)
   {
      var gameObjectQueue = new Queue<GameObject>(scene.GetRootGameObjects());

      while (gameObjectQueue.Count > 0)
      {
         var gameObject = gameObjectQueue.Dequeue();

         yield return gameObject;

         foreach (Transform child in gameObject.transform)
            gameObjectQueue.Enqueue(child.gameObject);
      }
   }
}

Теперь мы можем автоматически запускать эти тесты. В принципе, сейчас можно было бы остановиться. Но у текущих тестов есть несколько недостатков: неудобный вывод ошибок, тест остановится, если найдется хотя бы один невалидный GameObject. Стоит переписать их, чтобы повысить удобство пользования:

[Test]
public void MissingComponentsValidation()
{
    var errors =
        from scene in GetAllProjectScenesOpened()
        from gameObject in GetAllGameObjects(scene)
        where GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(gameObject) > 0
        select $"On scene {scene.name} game object {gameObject.name} has missing component";

   Assert.That(errors, Is.Empty);
}

[Test]
public void MissingPrefabsValidation()
{
    var errors =
        from scene in GetAllProjectScenesOpened()
        from gameObject in GetAllGameObjects(scene)
        where PrefabUtility.IsPrefabAssetMissing(gameObject)
        select $"On scene {scene.name} game object {gameObject.name} has missing prefab";

   Assert.That(errors, Is.Empty);
}

Плюсы такого подхода:

  • Тесты можно добавить в CI и настроить так, чтобы мерж не проходил, если тесты не зеленые
  • Если есть тяжелые тесты, то можно их ставить на ночные билды и не тратить время на то, чтобы дождаться их результатов
  • Просто покрытые фичами тесты не так страшно рефакторить

Минусы

  • Тесты тоже надо поддерживать
  • При плохо написанных тестах они скорее раздражают, чем помогают

Дополнительные ссылки:

Проверено в Unity 2020.3.12f1