Составление тестовых данных на основе фиксирующего элемента

Составление тестовых данных на основе фиксирующего элемента

1. Введение

Во время реализации проекта часто возникает ситуация, когда при понимании необходимости "написать тестовый код" мы откладываем это из-за нехватки времени. Я сам через это проходил. В начале разработки бэкенд-сервиса я проверял функции вручную через Insomnia или интерфейс без тестового кода. Однако с увеличением масштаба сервиса и усложнением взаимосвязей между доменами случаи, когда изменение одной функции влияло на другие, стали чаще. Проверить все эти пути только с помощью ручного тестирования стало трудно, и я часто сталкивался с неожиданными ошибками в функциях после изменения кода. Этот опыт побудил меня всерьез заняться интеграционным тестированием, и в этой статье я хотел бы поделиться опытом проектирования Fixture, особенно в подготовке тестовых данных.

2. Обзор тестовой среды

Интеграционная тестовая среда была организована следующим образом. Мы загрузили весь контекст приложения, используя @SpringBootTest и H2 в памяти, и проводим тесты без внешней базы данных. Мы изолировали зависимости внешних сервисов с помощью @MockBean. Кроме того, перед каждым тестом выполняем TRUNCATE, чтобы очистить все таблицы и предотвратить помехи между тестами. Мы настроили тестовый класс на наследование от этого базового класса для создания последовательной тестовой среды.

@SpringBootTest(classes = ServiceBootApplication.class)
public abstract class FeatureH2BootTestSupport {

    @MockBean
    protected OtherServiceClient otherServiceClient;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    void clearDatabase() {
        jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE");
        List<String> tableNames = jdbcTemplate.queryForList(  
                  "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
                   WHERE TABLE_SCHEMA = 'PUBLIC'", 
                  String.class        
        );
        for (String tableName : tableNames) {
            jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
        }
        jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE");
    }
}

3. Причины необходимости Fixture

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

Pavilion → Cineroom → Stage → StageRoleSe
     └→   Subscription → Episode → AssignedEpisode → StagedEpisode

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

4. Проектирование Fixture

4.1 FixtureDefaults — централизованное управление константами

Первое, что я сделал, это собрал все идентификаторы и значения по умолчанию, используемые в тестах, в одном месте.

public class FixtureDefaults {
    public static final String SQUARE_ID = "TST";
    public static final String PAVILION_ID = SQUARE_ID + ":1";
    public static final String CINEROOM_ID = PAVILION_ID + ":1";
    public static final String STAGE_ID = CINEROOM_ID + "-1";
    public static final String SUBSCRIPTION_ID = PAVILION_ID + "-S00AA";
    public static final String EPISODE_ID = PAVILION_ID + "-" + EPISODE_CODE;
    public static final String ASSIGNED_EPISODE_ID =
            AssignedEpisode.genId(CINEROOM_ID, EPISODE_ID);
    public static final String STAGED_EPISODE_ID =
            StagedEpisode.genId(STAGE_ID, ASSIGNED_EPISODE_ID);
    // ...
}

Централизованное управление идентификаторами как константами позволило заранее подготовить правильные идентификационные данные и использовать их в нескольких тестах, что сократило время на настройку данных перед тестом. Кроме того, если в системе идентификаторов есть изменения, достаточно изменить только одно место, что уменьшает нагрузку при написании кода тестирования. В то же время, при написании тестового кода стало более удобно ссылаться на них, например, как `FixtureDefaults.PAVILION_ID`, что повысило читаемость.

4.2 Классы Fixture по доменам

Классы Fixture были созданы по каждому агрегату. Каждый Fixture зарегистрирован как @Component для инъекции в контекст Spring.

Методы классов Fixture были спроектированы в целом в три группы в зависимости от их назначения.

Метод gen создает объекты основных сущностей на основе констант FixtureDefaults. Он не сохраняет в базу данных и возвращает только объекты, что позволяет использовать его в Given-части теста для создания основных сущностей, а затем модифицировать определенные поля для установки желаемого состояния. Объявленный как статический метод, его можно вызывать отовсюду, даже без инъекции в контекст Spring.

метод genMore является опциональным методом для случаев, когда требуется дополнительная сущность такого же типа помимо основной сущности, но ее нельзя покрыть изменением одного-двух полей. Например, когда необходимо протестировать "ситуацию с двумя Pavilion", можно создать основную Pavilion с помощью genPavilion(), а дополнительную Pavilion с помощью genMorePavilion(). Параметры принимают разделительные значения (sequence, osid и т.д.), чтобы избежать конфликта с основной сущностью.

метод create сохраняет основную сущность, созданную с помощью метода gen, в реальной базе данных. Внутренне он вызывает метод gen и сохраняет через Store, поэтому одной строчки workspaceFixture.createPavilion() достаточно для подготовки основной Pavilion в базе данных. В большинстве тестов этого метода достаточно, и только в случаях, когда требуется кастомизация данных, используются методы gen напрямую.

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

@Component
@RequiredArgsConstructor
public class WorkspaceFixture {

    // workspace aggregate domain store
    private final PavilionStore pavilionStore;

    // gen
    public static Pavilion genPavilion() {
        Pavilion pavilion = new Pavilion();
        pavilion.setId(FixtureDefaults.PAVILION_ID);
        // ...
        return pavilion;
    }

    // genMore
    public static Pavilion genMorePavilion(String sequence, String osid) {
	String pavilionId = FixtureDefaults.SQUARE_CODE + “:” + sequence;
        Pavilion pavilion = new Pavilion();
        pavilion.setId(pavilionId);
        pavilion.setOsid(osid);
        // ...
        return pavilion;
    }

    // create
    public void createPavilion() {
        Pavilion pavilion = genPavilion();  // FixtureDefaults 기반 기본값
        pavilionStore.create(pavilion);
    }
}

5. Использование Fixture в реальных тестах

Тестовый код написан с использованием паттерна Given-When-Then. На этом этапе Fixture отвечает за подготовку предварительных данных, необходимых для теста. Давайте рассмотрим, как Fixture используется в реальных тестовых кодах.

5.1 Использование метода create — основной сценарий

@BeforeEach
void setUp() {
    workspaceFixture.createPavilion();
    workspaceFixture.createCineroom();
    workspaceFixture.createStage();

    subscriptionFixture.createSubscription();
    subscriptionFixture.createEpisode();
    subscriptionFixture.createAssignedEpisode();
    subscriptionFixture.createStagedEpisode();
}

@Test
@DisplayName("revokeEpisode는 assignedEpisode와 관련된 stagedEpisode를 모두 제거한다")
void revokeEpisode_removesAssignedAndStagedEpisodes() {
    // Given

    // When
    flow.revokeEpisode(FixtureDefaults.ASSIGNED_EPISODE_ID);

    // Then — Store로 DB 상태 직접 검증
    assertThat(assignedEpisodeStore.exists(FixtureDefaults.ASSIGNED_EPISODE_ID))
            .isFalse();
    assertThat(stagedEpisodeStore.exists(FixtureDefaults.STAGED_EPISODE_ID))
            .isFalse();
}

Это самый распространенный случай. В @BeforeEach общие предварительные данные подготавливаются с помощью метода create, а в разделе Given каждого теста создаются дополнительные данные, необходимые только для этого теста. Если бы Fixture не было введено, в методе setUp были бы перечислены десятки строк кода для непосредственного создания каждой сущности и настройки полей. Используя метод create, процесс подготовки данных сжимается до одной строки вызова метода, поэтому setUp становится списком, который декларативно показывает, "какие данные подготавливаются". Также, поскольку сущности создаются на основе констант, определенных в FixtureDefaults, можно безопасно устанавливать правильные данные без ошибок, таких как опечатки идентификаторов или пропуски в иерархических отношениях.

Когда общая настройка данных завершена в setUp, в разделе Given каждого конкретного теста можно оставить только уникальные условия этого теста или вообще ничего не оставлять. Если Given раздел пуст, как в приведенном примере, это означает, что "базовое состояние setUp является предпосылкой этого теста", и позволяет сосредоточиться на самом важном — When-Then.

5.2 Использование метода gen — кастомизация данных

@Test
@DisplayName("Dormant 상태의 subscription에 subscribed 이벤트가 오면 Active로 변경한다")
void subscribed_reactivatesSubscription_whenDormantState() {
    // Given — gen으로 만들고 상태를 커스터마이징
    Subscription subscription = SubscriptionFixture.genSubscription();
    subscription.setState(SubscriptionState.Dormant); // 기본값(Active)을 변경
    subscriptionStore.create(subscription);
    
    // When
    flow.subscribed(createSubscribeRequestSdo(FixtureDefaults.PAVILION_ID));
    
    // Then
    assertThat(subscriptionStore.retrieve(FixtureDefaults.SUBSCRIPTION_ID).isActive())
            .isTrue();
}

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

5.3 Использование метода genMore — сценарии с несколькими данными

Для тестов, где требуется несколько сущностей одного типа, используется метод genMore.

@Test
void stageEpisode_stagesNewAndStashesRemovedStagedEpisodes() {
     subscriptionFixture.createAssignedEpisode();
     subscriptionFixture.createStagedEpisode();

     // Given — 추가 Episode 생성
     Episode otherEpisode = SubscriptionFixture.genEpisodeMore("E00AB");
     episodeStore.create(otherEpisode);
     AssignedEpisode otherAssigned =
              SubscriptionFixture.genAssignedEpisodeMore(
                      FixtureDefaults.CINEROOM_ID, otherEpisode);
     assignedEpisodeStore.create(otherAssigned);

     // When — 새 Episode로 교체
     flow.stageEpisode(FixtureDefaults.STAGE_ID,
               List.of(otherAssigned.getId()));
   
     // Then — 기존 것은 제거되고 새로운 것만 존재
     assertThat(stagedEpisodeStore.exists(FixtureDefaults.STAGED_EPISODE_ID))
              .isFalse();
     assertThat(stagedEpisodeStore.exists(
              StagedEpisode.genId(FixtureDefaults.STAGE_ID, otherAssigned.getId())))
             .isTrue();
}

genMore принимает разделительные значения в качестве параметров и создает сущности, которые не конфликтуют с основным Fixture и ID. В домене Episode изменения ID влияют на поля с высоким количеством изменений, поэтому вместо метода 5.2 был выбран метод genMore. Таким образом, можно легко создать "ситуацию, в которой одновременно существуют существующие и новые данные".

6. Заключение

Сначала работа по настройке Fixture и константных данных может показаться обременительной. Однако, как только она завершена, это значительно помогает в написании тестового кода. На первоначальном этапе, прежде чем была организована система FixtureDefaults и Fixture для различных доменов, приходилось копировать логику настройки данных каждый раз при написании тестового кода, и это занимало немало времени. Но с тех пор, как была организована система Fixture, больше не нужно тратить время на подготовку предварительных данных.

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

Rosalyn

Site footer