Рубрики
Без рубрики

Как сделать ваши тесты более удобочитаемыми и удобными для обслуживания

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

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

Вступление

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

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

  1. Подготовка: Подготовьте сценарий, в котором мы хотим выполнить тестовое действие. Это означает настройку тестового проигрывателя со всеми условиями и требованиями, необходимыми для тестового действия.
  2. Выполнение: Запустите действие, которое мы хотим протестировать. В большинстве случаев мы вызываем здесь конечную точку API.
  3. Утверждение: Подтвердите, что результат именно тот, которого мы ожидаем.

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

Одна из проблем, с которой мы всегда сталкивались, заключалась в том, что этап подготовки уже мог сделать тесты нечитаемыми даже в очень простых тестовых сценариях. Давайте возьмем в качестве примера игру city builder. Мы хотим написать тест, в котором мы собираем готовую продукцию из здания, которое производит монеты.

Подготовительный этап состоит из нескольких этапов:

  • Создайте нового игрока
  • Создайте город для этого игрока (представьте, что у нас может быть несколько городов для каждого игрока)
  • Поместите в город здание, которое может производить монеты
  • Создайте готовую продукцию, которую можно собирать
  • Установите начальное количество монет игрока, чтобы выполнить надлежащую проверку

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

@Test
public void testCollectProduction() {
    var player = playerTestHelper.createPlayer();
    var city = cityTestHelper.createMainCity(player);
    var building = buildingTestHelper.createBuilding(city, "SomeProductionBuilding");
    productionTestHelper.createFinishedProduction(building, ResourceConstants.COINS, 20);
    resourceTestHelper.setAmount(player, ResourceConstants.COINS, 100);

    // call collect production endpoint

    // assert that I have 120 coins
}

Но, судя по нашему опыту, это не всегда так просто. В какой-то момент вам нужно быть более гибким. Либо вы в конечном итоге получаете больше параметров во вспомогательном методе, либо вам приходится создавать все больше и больше вспомогательных методов, чтобы тест оставался читаемым. Это может произойти очень быстро, что код подготовки будет больше, чем остальная часть теста. Глядя на этот тест через месяц, вам нужно несколько мгновений, чтобы понять, что происходит.

Обеспечение возможности чтения тестовой настройки

Я хотел бы сразу понять, что делает тест, даже когда я возвращаюсь к нему через год. Для достижения этой цели мы внедрили API, подобный builder, который позволяет нам определять весь тестовый сценарий, просто настраивая объект сценария:

@Test
public void testCollectProduction() {
    buildScenario(scenario -> scenario
        .withPlayer(player -> player
            .withResource(ResourceConstants.COINS, 100)
            .withCity("MainCity", city -> city
                .withBuilding("SomeProductionBuilding", building -> building
                    .withProduction(production -> production
                        .withResource(ResourceConstants.COINS)
                        .withAmount(20)
                        .withFinishNow()
                    )
                )
            )
        )
    );

    // call collect production endpoint

    // assert that I have 120 coins
}

Как вы можете видеть, тестовая настройка намного проще для чтения. Отступ дает понять, какая конфигурация принадлежит какому объекту и какой объект принадлежит какому родительскому объекту. Подобный построителю подход наряду с автозаполнением также делает написание таких тестов увлекательным. Нам не нужно думать о тестовых вспомогательных классах, которые необходимо внедрить, и о том, есть ли метод, который обеспечивает необходимую мне функциональность. Нам просто нужно настроить объект сценария с помощью предоставленных with-методов. Фактическое создание объектов происходит в фоновом режиме в методе build Scenario() .

Но откуда мы получаем сгенерированные идентификаторы? Мы создали объект building в фоновом режиме, но нам нужно знать сгенерированный идентификатор для действия “сбор продукции”. Для этого случая мы можем использовать ссылочные объекты:

@Test
public void testCollectProduction() {
    // create a reference object
    var buildingRef = new AtomicReference();

    buildScenario(scenario -> scenario
        .withPlayer(player -> player
            .withResource(ResourceConstants.COINS, 100)
            .withCity("MainCity", city -> city
                .withBuilding("SomeProductionBuilding", building -> building
                    .entityRef(buildingRef) // <-- reference object should contain this building entity
                    .withProduction(production -> production
                        .withResource(ResourceConstants.COINS)
                        .withAmount(20)
                        .withFinishNow()
                    )
                )
            )
        )
    );

    // call collect production endpoint with buildingId: buildingRef.get().getId()

    // assert that I have 120 coins
}

Для справки мы можем использовать любой простой держатель справки. Java уже поставляется с классом AtomicReference , который предоставляет необходимую нам функциональность, поэтому мы используем этот класс по соображениям удобства. Мы просто создаем ссылочный объект перед построением сценария (конечно, в этот момент этот объект пуст). Когда build Scenary() создает объекты, ссылочные объекты также заполняются созданными объектами. Это означает, что после вызова build Scenary() мы можем получить доступ к объектам через referenceObject.get() .

Это мило! Но вся мощь ссылочных объектов будет ясна в следующем примере. Допустим, у нас есть рабочие, которых мы можем отправить в здания, чтобы увеличить его. Как мы можем назначить здание рабочему, если здание еще не создано? Просто используйте ссылочный объект:

@Test
public void testCollectProduction() {
    // create a reference object
    var buildingRef = new AtomicReference();

    buildScenario(scenario -> scenario
        .withPlayer(player -> player
            .withResource(ResourceConstants.COINS, 100)
            .withCity("MainCity", city -> city
                .withBuilding("SomeProductionBuilding", building -> building
                    .entityRef(buildingRef)  // <-- reference will be filled with this building entity
                    .withProduction(production -> production
                        .withResource(ResourceConstants.COINS)
                        .withAmount(20)
                        .withFinishNow()
                    )
                )
                .withWorker(worker -> worker
                    .withAssignedBuilding(buildingRef)  // <-- use the referenced building here
                )
            )
        )
    );

    // ...
}

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

Осуществление

Есть два основных компонента. Вы уже видели объект сценария в действии. Этот и все его дочерние элементы называются Классы конфигурации (все они имеют префикс “Given”). Кроме того, у нас есть Классы настройки (С суффиксом “Setup”), которые заботятся о фактическом создании объекта.

Классы конфигурации

Для каждой логики или сущности домена должен быть класс конфигурации, который может существовать в качестве требования к тестированию. Корневым классом является класс Данного сценария . Создается экземпляр этого класса, объект scenario, который можно настроить, вызвав метод build Scenario() , который вы видели в примерах ранее.

В нашем случае данный объект сценария содержит список заданных объектов игрока, данный игрок содержит ресурсы и города игрока и так далее. Это должно отражать вашу бизнес-логику. Итак, если здание принадлежит городу, данный городской объект должен содержать данные строительные объекты.

Обычно это класс конфигурации …

  • содержит “with”-методы для его настройки. Метод with – это своего рода метод установки, но должен
    • всегда возвращайте свой собственный экземпляр, чтобы обеспечить свободный API
    • примите параметр Consumer для настройки дочернего объекта
  • содержит методы получения для всех конфигураций, которые должны использоваться классами установки
  • содержит ссылку на Сущность, созданную классом Setup. Мы абстрагировали эту часть от Данной сущности класса, который вы можете найти в следующем примере.
public class GivenScenario {

    @Getter
    private final List players = new ArrayList<>();

    public GivenScenario withPlayer(Consumer playerConsumer) {
        GivenPlayer givenPlayer = new GivenPlayer();
        playerConsumer.accept(givenPlayer);
        players.add(givenPlayer);
        return this;
    }

}
public class GivenPlayer extends GivenEntity {

    @Getter
    private final Map resources = new HashMap<>();

    @Getter
    private final List cities = new ArrayList<>();

    public GivenPlayer withResource(ResourceConstants resourceId, long amount) {
        resources.put(resourceId.getKey(), amount);
        return this;
    }

    public GivenPlayer withCity(String cityDefinitionId, Consumer cityConsumer) {
        GivenCity givenCity = new GivenCity(cityDefinitionId);
        cityConsumer.accept(givenCity);
        cities.add(givenCity);
        return this;
    }

    public GivenPlayer withCity(String cityDefinitionId) {
        cities.add(new GivenCity(cityDefinitionId));
        return this;
    }

}
public abstract class GivenEntity {

    private AtomicReference entityReference = new AtomicReference<>();

    @SuppressWarnings("unchecked")
    public G entityRef(AtomicReference ref) {
        if (entityReference.get() != null) {
            ref.set(entityReference.get());
        }
        entityReference = ref;
        return (G) this;
    }

    public E getEntity() {
        var entity = entityReference.get();
        if (entity == null) {
            throw new IllegalStateException("Entity not set");
        }
        return entity;
    }

    public void setEntity(E entity) {
        entityReference.set(entity);
    }

}

Классы настройки

Класс установки отвечает за построение определенной части тестового сценария. В большинстве случаев для каждой логики или сущности домена существует класс настройки.

Например, класс настройки города отвечает за создание всех городов, определенных в сценарии. Building Setupclass создает здания, а также заботится о запуске своих производств.

Вот пример класса CitySetup:

@Component
@Order(ScenarioSetupPartOrder.CITY)
@RequiredArgsConstructor
public class CitySetup implements ScenarioSetupPart {

    private final CityService cityService;
    private final CityInitService cityInitService;
    private final GameDesignService gameDesignService;

    @Override
    public void setUp(GivenScenario scenario) {
        scenario.getPlayers().forEach(givenPlayer -> givenPlayer
            .getCities().forEach(givenCity -> createCity(givenPlayer, givenCity)));
    }

    private void createCity(GivenPlayer givenPlayer, GivenCity givenCity) {
        Player player = givenPlayer.getEntity();
        CityDefinition cityDefinition = gameDesignService.get(CityDefinition.class, givenCity.getCityDefinitionId());

        City city = cityService.find(player.getId(), cityDefinition)
            .orElseGet(() -> cityInitService.startNewCity(player, cityDefinition, Instant.now()));

        givenCity.setEntity(city);
    }

}
public interface ScenarioSetupPart {

    void setUp(GivenScenario scenario);

}
public class ScenarioSetupPartOrder {

    public static final int PLAYER = 1;
    public static final int RESOURCE = 2;
    public static final int CITY = 3;
    public static final int EXPANSION = 4;
    public static final int BUILDING = 5;

}

В то время как метод setUp() получает полный объект сценария, он должен просто выбрать информацию, необходимую для создания объектов. После создания объекта его следует передать объекту конфигурации (например, given City.setEntity(city) ), который затем заполняет ссылочный объект, чтобы сделать его доступным в следующих классах настройки и тестах.

Поскольку некоторые настроенные классы зависят от других, порядок важен. Например, класс настройки City получает доступ к сущности Player, которая была создана ранее с помощью Настройки Player. Порядок может быть обеспечен с помощью Order-Annotation для каждого класса настройки, как вы можете видеть в приведенном выше примере. Мы также создали класс заказа детали настройки сценария, чтобы легко поддерживать заказ. Аннотация заказа является частью Spring Framework. Если вы вводите список зависимостей (например, List ), он гарантирует, что этот список отсортирован в соответствии с определенным порядком.

Собирая все воедино

Теперь, когда у нас определены классы конфигурации и настройки, мы можем посмотреть на метод build Scenario() , который следует поместить в базовый тестовый класс:

protected void buildScenario(Consumer scenarioConsumer) {
    scenario = new GivenScenario();
    scenarioConsumer.accept(scenario);
    scenarioSetup.setUp(scenario);
}

Он создает объект данного сценария каждый раз, когда он вызывается. Параметр Consumer позволяет вызывающей стороне настраивать объект. Впоследствии вызывается метод scenary Setup.setUp() , который просто делегирует определенные классы настройки в правильном порядке:

@Component
@RequiredArgsConstructor
public class ScenarioSetup {

    private final List setupParts;

    @Transactional
    public void setUp(GivenScenario scenario) {
        for (ScenarioSetupPart part : setupParts) {
            part.setUp(scenario);
        }
    }

}

Вот и все! Когда мы создаем новую функцию, нам просто нужно создать конфигурацию и соответствующий класс настройки. После этого его можно использовать в любом тесте.

Вывод

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

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

Но если вы подумаете дальше, эта система откроет для нас новые пути. Этап подготовки – это всего лишь вопрос настройки объекта прямо сейчас. Настройка должна выполняться не только в ходе теста. Мы также создали конечную точку API для наших интерфейсных тестов автоматизации и внутриигровых читов. Теперь они могут использовать ту же систему для настройки проигрывателя без каких-либо дополнительных усилий. Но это кое-что для следующего поста в блоге 😉

Оригинал: “https://dev.to/christianblos/how-to-make-your-tests-more-readable-and-maintainable-9m1”