Почему вам следует избегать тестирования недетерминированного поведения

Здравствуйте, и добро пожаловать в четвертую статью из серии Давайте понюхаем тесты. В предыдущем эпизоде¹ мы рассмотрели тесты, проверяющие внутреннее поведение, и указали на недостатки этого подхода.

Сегодня мы собираемся разобрать еще одну интересную тему: почему тестирование нетестируемого — это плохая практика и почему оно приводит к переусложнению тест-кейсов.

Вот что я использую в сегодняшних примерах:

  • Ява 17
  • Юнит 5.8.2
  • УтвердитьJ 3.22.0
  • Мокито 4.5.1

Пример: создайте игрока в случайном месте на карте.

Требования

Требования к этому варианту использования следующие:

  • Когда игра начнется, создайте нового игрока на карте.
  • Карта представляет собой матрицу тайлов; размер карты можно настроить
  • По умолчанию поместите игрока на случайную плитку.

Первый подход к реализации

Пример реализации этих требований может выглядеть так:

Краткое объяснение:

  • Класс Board состоит из матрицы объектов, каждый из которых относится к типу Tile
  • Класс Tile представлен свойствами x и y, а также содержит набор объектов Localizable, таких как игроки; в будущем интерфейс Localizable может также представлять другие объекты, такие как враги, расходуемые предметы и все, что можно привязать к тайлу на карте.
  • Поскольку мы хотим разместить нового игрока где-то на карте, класс Player реализует интерфейс Localizable, который поставляется с функцией getCurrentLocation().
  • Чтобы создать новый экземпляр Player, мы должны вызвать фабричный метод create(Board board).
  • Для удовлетворения требований класс Player содержит логику для выбора случайного тайла, с которого игрок должен начать игру.

Анализ кода

Код можно либо написать так, чтобы он просто работал, либо он будет работать и сохранять высокое качество. К сожалению, текущая реализация представляет собой первый вариант.

Приведенный выше код компилируется, тест проходит, и теоретически мы могли бы просто двигаться дальше и реализовывать другие функции, но давайте посмотрим, что можно было бы сделать лучше:

  • Тестовый пример SpawnPlayerTest#testSpawnPlayerOnTheMap проверяет слишком много, проверяя размер доски (название теста предполагает, что оно фокусируется только на появлении игрока, что не соответствует действительности)
  • Логика утверждения слишком сложная (поскольку тайл, на который назначен игрок, случайный, мы собираем все тайлы с доски и проверяем, был ли игрок назначен на один из них)
  • Логика выбора тайла для нового игрока жестко запрограммирована в классе Player (нарушение принципов Single Responsibility и Open-Closed)

Как улучшить код

Работа со случайностью может быть сложной, и в нашем коде это так. Согласно этому примеру у нас есть доска размером 5х4 плитки. Это дает нам двадцать возможных плиток, из которых можно выбрать плитку для возрождающегося игрока.

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

Решение, которое могло бы лучше соответствовать текущим требованиям, можно описать несколькими пунктами:

  • Вынести логику выбора тайла для игрока в отдельный блок
  • Создайте абстракцию для этой логики, чтобы при изменении требований можно было использовать другую реализацию.
  • Чтобы заспавнить игрока, передайте селектор плитки классу Player через его конструктор.
  • Упростите тест SpawnPlayerTest#testSpawnPlayerOnTheMap

Шаг 1: Извлеките выбор тайла для игрока

Если в будущем требования изменятся, нам нужно будет написать еще один класс, который также будет реализовывать интерфейс LocationSeed.

Шаг 2: Сообщите объекту игрока, где взять начальную плитку

Конструктор использует предоставленный объект LocationSeed, вызывая метод locationSeed.getTile(). В будущем мы можем просто предоставить другую реализацию интерфейса LocationSeed, когда это необходимо, чтобы мы были закрыты для модификаций, но открыты для расширений.

Шаг 3. Переработайте тест

Я выделил логику создания платы в отдельный тестовый метод в новом тестовом классе. Этот тест точно подтверждает то, что следует из его названия.

Отныне старый тест упрощен. Он ориентирован исключительно на создание игрока.

Я использовал метод mock() из Mockito для создания поддельной реализации класса RandomLocationSeed. Поначалу это может показаться немного нелогичным, потому что мы хотели, чтобы наш тест проверял, работает ли создание игрока так, как ожидалось.

Помните, что мы должны тестировать наблюдаемое поведение, то есть «порождать игрока в случайном месте на карте». Однако мы хотим настроить этот тест с зависимостями, которые мы можем контролировать, потому что случайность — это внешний фактор, который мы не можем контролировать.

Затем я настроил макет, вызвав функции when() и then(), также предоставляемые Mockito. Эти функции используются для настройки того, что следует делать при вызове метода. Я просто запросил объект Board, чтобы вернуть одну из своих плиток, и всякий раз, когда вызывается функция getTile(), она возвращает образец плитки.

Последнее, что касается мокирования LocationSeed, — это вызов метода verify(), который проверяет, был ли вообще вызван мок.

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

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

Заключение

Как видите, программировать игры может быть так же весело, как и играть в них :).

Подытожим уроки, извлеченные из этого примера:

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

Рекомендации

[1]: Кристиан Шпичаковски, Давайте понюхаем тесты #2 https://betterprogramming.pub/lets-smell-some-tests-2-asserting-the-internal-behavior-in-java -1c0f34fe8bbc