Как стать автором
Обновить

Тестируем работу с БД из SpringBoot: TestContainers, DBUnit и все-все-все

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров10K

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

А еще для приложений пишут тесты, чтобы проверить корректность их работы. И когда возникает необходимость протестировать работу приложения с БД, то выясняется, что здесь могут быть свои тонкости. За подробностями добро пожаловать под кат.

Интеграционные тесты с БД

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

Проверять на уровне API довольно просто (вызвали API для записи, проверили через API для чтения). Раньше я не любил такие тесты и вместо них предпочитал проверять непосредственно состояние БД после выполнения теста. Мне казалось, что тестирование через API во многом имитация интеграционного теста: вы дернули пару методов и сравнили результаты. Да может приложение вообще до базы не дошло? Особенно, если вы какой-нибудь JPA используете. Но потом я сместил акценты. Да, такие тесты можно написать плохо. Но и любые другие - тоже. Немного экспериментов и у вас будет простой набор паттернов, помогающих свести вероятность ошибок к минимуму. Например, можно использовать разные транзакции для разных фаз теста (подготовка-тест-валидация) и писать негативные тесты на недоступность базы.

Однако, ваша база сама по себе может быть частью API сервиса, если, к примеру, у вас настроены ETL-процедуры для экспорта ваших данных в какое-то внешнее хранилище. И тогда проверка наполнения базы обязательна. Иногда также бывает необходимо убедиться в том, что какой-нибудь хитрый маппинг сущностей работает так, как вы ожидали. В общем, проверять реальные данные в БД может быть весьма полезно. Для таких проверок я предпочитаю использовать DBUnit. Это библиотека облегчает начальное заполнение и валидацию конечного состояния БД. Она поддерживает декларативное описание слепков состояний базы через XML-файлы и другие форматы данных. Но настроить связку SpringBootTest (сами тесты) + TestContainers (для запуска БД) + Liquibase (для раскатывания схемы БД) + DBUnit - не очень простая задача. Собственно, этому и посвящена данная статья.

Пишем тесты с DBUnit

В качестве приложения, для которого будут писаться тесты, я взял небольшой сервис, который писал для своей другой статьи (сама статья, исходный код). Сервис представляет собой очень простой CRUD, который работает с базой через Spring Data JDBC. Одной из его особенностей является то, что операции чтения и записи разнесены по разным компонентам. Это влияет на тестирование, поскольку для операций чтения нам важно контролировать наполнение БД, а для операций записи - валидировать состояние таблиц после этих операций. Посмотрим, что нужно сделать с приложением для того, чтобы запустить тесты на DBUnit.

build.gradle

// SpringBoot test
testImplementation ('org.springframework.boot:spring-boot-starter-test') {
    exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}

// Test containers for JUnit 5
testImplementation "org.testcontainers:postgresql:${testContainersVersion}"
testImplementation "org.testcontainers:junit-jupiter:${testContainersVersion}"

// DBUnit
testImplementation "org.dbunit:dbunit:${dbUnitVersion}"

// DBUnit integration for SpringBoot test
testImplementation "com.github.springtestdbunit:spring-test-dbunit:${springTestDbUnitVersion}"

// Liquibase for DB schema preparation
testRuntimeOnly "org.liquibase:liquibase-core:${liquibaseVersion}"

Посмотрим на зависимости:

  • со spring-boot-starter-test все просто, он нужен для интеграционных тестов со Spring'ом. Я отцепляю транзитивную зависимость от spring-boot-starter-logging, потому что предпочитаю использовать Log4J2 вместо SLF4J;

  • зависимости для testcontainers и DBUnit говорят сами за себя;

  • liquibase добавляем в тест для создания схемы БД;

  • и остается spring-test-dbunit - это небольшая вспомогательная библиотека, позволяющая удобно подключать проверки DBUnit в тесты для Spring'а. К сожалению, она не развивается, последний релиз был около 6 лет назад. Тем не менее, она по-прежнему корректно работает с последними версиями Spring'а.

test/resources/config/application.yaml

spring:
  datasource:
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
    url: jdbc:tc:postgresql:15:///posts-db

В тестовом application.yaml говорим, что мы используем TestContainers. SpringBoot попробует создать дефолтный DataSource, а указанный ContainerDatabaseDriver уже сделает всю нужную магию (поднимет контейнер с postgres:15 и настроит DataSource для использования этого контейнера). В этом же файле можно объявить местоположение liquibase-миграций, я этого не делаю, потому что подключаю их в качестве исходников для тестов в build.gradle.

Обратите внимание на местоположение файла - он лежит в директории config. Spring будет использовать основной application.yaml из корня classpath'а и дополнять его данными из config/application.yaml. Механизм описан здесь. Это позволяет не использовать отдельный профиль для теста и не дублировать все свойства основного application.yaml в тестовом.

posts.xml

<dataset>
    <post id="1" author="tester" subject="test subject" 
          text="test post body" published="true"/>
    <post id="2" author="tester" subject="one more subject" 
          text="one more test post body" published="false"/>
    <post id="3" author="another" subject="another subject" 
          text="another post body" published="true"/>
</dataset>

Это описание наполнения БД, которое использует DBUnit. Такие файлы могут использоваться и для начального наполнения БД, и для валидации результатов. Я всецело понимаю тех из вас, у кого аллергия на XML-файлы. Но это лучшее, что мне попадалось на глаза для работы с тестами БД. По крайней мере, это совершенно точно лучше, чем вставлять вcе эти строки на чистом JDBC.

Структура файла довольно очевидна - корневой тэг dataset, в нем - набор дочерних тэгов. Каждый дочерний тэг - строка в таблице. Имя тэга - имя таблицы. Имя атрибута - имя столбца, значение - ну, собственно, значение в столбце.

ReadOperationsImplTest.java

А вот теперь посмотрим, как все это совместить в одном тесте. Сначала изучим, как DBUnit можно использовать для начального наполнения БД в тестах.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureDataJdbc
@EnableJdbcRepositories(basePackages = "codes.bespoke.brastak.snippets.zero.repository")
@ContextConfiguration(classes = { PostMapperImpl.class, ReadOperationsImpl.class })
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestExecutionListeners({
    DependencyInjectionTestExecutionListener.class,
    DbUnitTestExecutionListener.class
})
public class ReadOperationsImplTest {
    @Autowired
    private ReadOperationsImpl readOperations;

    @Test
    @DatabaseSetup(value = "classpath:db/posts.xml")
    public void testFindById_success() {
        Optional<PostDto> actual = readOperations.findById(1);
        Optional<PostDto> expected = Optional.of(new PostDto(1, "tester", "test subject",
            "test post body", true));
        Assertions.assertEquals(expected, actual);
    }
  }

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

  • SpringBootTest + AutoConfigureDataJdbc + AutoConfigureTestDatabase - создаем контекст теста и все компоненты, которые нужны для работы Spring Data JDBC. Вообще можно использоватьDataJdbcTest, но она помечает все методы как Transactional (а нам это сейчас не требуется), и нам все равно надо будет сказать AutoConfigureTestDatabase(NONE)), чтобы спринг не пытался использовать тестовый DataSource;

  • EnableJdbcRepositories - тут просто, говорим, где лежат наши репозитории. Относительно теста они находятся в другом пакете, поэтому его приходится указывать явно;

  • ContextConfiguration - тут тоже просто, перечисляем зависимости сервиса, который будем тестировать (в том числе, транзитивные);

  • TestExecutionListeners({DependencyInjectionTestExecutionListener.class, DbUnitTestExecutionListener.class}) - DbUnitTestExecutionListener нужен для DBUnit'а. Внутри он смотрит, не помечен ли тестовый метод специальными аннотациями, и если помечен, то либо выполняет начальное заполнение БД, либо проверяет финальное состояние БД после теста. Но, используя TestExecutionListeners, мы переписываем стандартные Listener'ы, поэтому надо добавить DependencyInjectionTestExecutionListener, чтобы тестируемый бин вообще создался;

  • DatabaseSetup - аннотация DBUnit'а, содержащая данные для начального заполнения базы. DBUnit при выполнении теста прочитает этот XML, поймет, в какие таблицы надо писать данные, удалит все из этих таблиц, и вставит те данные, которые нужны. Этот момент можно настраивать в самой аннотации, по умолчанию используется стратегия CLEAN_INSERT. Здесь тонкий момент - эта операция выполняется ДО запуска теста. Если какой-то тест ожидает, что в таблицах будет пусто, то он должен явно это объявить в XML-файле. Но более правильный вариант - использовать для каждого теста ещё одну аннотацию DatabaseTearDown, которая будет отвечать за очистку БД.

Итого, при старте теста происходит следующее: создается Spring'овый контекст, в этом контексте объявлен DataSource с настройками для TestContainers. TestContainers поднимает docker-образ с базой. Liquibase накатывает схему БД. Завершается создание всех нужных Spring-бинов, запускается сам тест. Срабатывает DBUnit-listener, он понимает, что перед выполнением теста надо предзаполнить таблицу posts. Он сначала удаляет оттуда все записи, а потом вставляет новые. И только после этого выполняется первая команда теста.

Полезные улучшения

Диалект базы

Если запустить этот тест, он отработает корректно, но выдаст предупреждение: Potential problem found: The configured data type factory 'class org.dbunit.dataset.datatype.DefaultDataTypeFactory' might cause problems with the current database 'PostgreSQL'. Мы можем явно сказать DBUnit'у, что мы используем PostgreSQL.

...
public class ReadOperationsImplTest {
    @TestConfiguration
    static class DbUnitConfiguration {
        @Bean
        public DatabaseDataSourceConnectionFactoryBean dbUnitDatabaseConnection(DataSource dataSource) {
            DatabaseConfigBean bean = new DatabaseConfigBean();
            bean.setDatatypeFactory(new PostgresqlDataTypeFactory());

            DatabaseDataSourceConnectionFactoryBean dbConnectionFactory = new DatabaseDataSourceConnectionFactoryBean(dataSource);
            dbConnectionFactory.setDatabaseConfig(bean);
            return dbConnectionFactory;
        }
    }
    ...
}

Вообще, такие конфигурации должны подтягиваться тестами без дополнительных усилий, но мне пришлось явно дописывать ее в @ContextConfiguration. Обратите внимание, что имя бина здесь важно, оно должно быть именно dbUnitDatabaseConnection.

Dirty Context

Интеграционные тесты могут быть дорогими в выполнении, поэтому часто используют подход с DirtyContext. В нашем случае, чтобы его использовать, надо добавить в список TestExecutionListeners DirtiesContextTestExecutionListener.class.

А чем такое наполнение базы лучше использования @Sql?

Spring и сам по себе позволяет подготавливать БД для тестов. У него для этого есть аннотация Sql, которой просто передается файл со списком команд. И не надо вот этого вот всего. Собственно, это даже не единственный способ, которым спринг можно заставить выполнить какой-то SQL-скрипт. Если все, что нужно сделать - наполнить базу для тестов, то DBUnit обычно лишний. Но и здесь у него есть пара полезных фич:

  • поддержка бинарных данных (есть возможность передать в BASE64, либо указывать, что содержимое надо загрузить из файла / URL);

  • поддержка относительного времени (можно указать, что в таблицу должно попасть время, отстающая от текущей на 5 минут. Или время, которое было 3 дня назад в 15:45).

Но реальная польза в том, что с DBUnit'ом вы можете использовать одни и те же файлы и для наполнения базы, и для проверки ее конечного состояния в разных тестах.

WriteOperationsImplTest.java

А теперь более интересный тест. Мы будем тестировать операции записи и валидировать, что же реально попало в БД.

При этом может поменяться наш подход. Основной камень преткновения - транзакции. Скорее всего, мы их используем, и в приложении наш тестируемый компонент вызывается внутри транзакции. Поэтому мы должны наши тесты тоже вызывать в транзакции. Спринг это поддерживает, DataJdbcTest вообще все тесты запускает в транзакциях. Нам надо поменять DBUnit-listener на транзакционный и сказать, что наши тесты теперь Transactional. А дальше есть развилка.

Spring по умолчанию считает, что транзакции в тестах не надо коммитить. Он сделает все операции в БД, а потом скажет rollback. Это не совсем честно, но в большинстве случаев приемлемо. Из потенциальных проблем мне в голову приходят только проблемы с конкурентным доступом, которые мы можем не отловить таким тестом. Но такие кейсы редко тестируются интеграционно.

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureDataJdbc
@Commit
@Transactional
@EnableJdbcRepositories(basePackages = "codes.bespoke.brastak.snippets.zero.repository")
@ContextConfiguration(classes = { PostMapperImpl.class, WriteOperationsImpl.class })
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestExecutionListeners({
    DependencyInjectionTestExecutionListener.class,
    TransactionDbUnitTestExecutionListener.class
})
@DatabaseTearDown(value = "classpath:db/clean.xml", type = DatabaseOperation.TRUNCATE_TABLE)
public class WriteOperationsImplTest {
    @Autowired
    private WriteOperationsImpl writeOperations;

    @Test
    @ExpectedDatabase(value = "classpath:db/single-post.xml", table = "post", 
                      assertionMode = DatabaseAssertionMode.NON_STRICT, 
                      columnFilters = IdColumnFilter.class)
    public void testCreatePost() {
        CreatePostRequestDto createPostRequest = new CreatePostRequestDto("tester", "test subject", "test post body");
        PostDto post = writeOperations.create(createPostRequest);
        Assertions.assertTrue(post.id() > 0);
        Assertions.assertTrue(post.published());
    }

    @Test
    @DatabaseSetup(value = "classpath:db/single-post.xml")
    @ExpectedDatabase(value = "classpath:db/hidden-post.xml", table = "post")
    public void testHidePost() {
        writeOperations.hide(1);
    }

    public static class IdColumnFilter implements IColumnFilter {
       @Override
       public boolean accept(String tableName, Column column) {
          return tableName.equalsIgnoreCase("post")
             || column.getColumnName().equalsIgnoreCase("id");
        }
    }
}

Посмотрим на новые настройки для теста:

  • про TransactionDbUnitTestExecutionListener, Transactional и Commit я уже писал выше;

  • ExpectedDatabase - аннотация, описывающая желаемое состояние БД. Без дополнительных параметров проверяется вся база целиком, что обычно не нужно. Параметр table говорит, что проверяем конкрентную таблицу (в моем случае надо было исключить из сравнения таблицу Liquibase'а). Второй момент - какие именно столбцы проверяются и важен ли порядок строк. За это отвечает параметр assertionMode. Параметр columnFilters будет разобран ниже.

Важный момент - валидация проходит только на точное равенство значений. Это ограничение библиотеки spring-test-dbunit, которая и предоставляет аннотации для упрощенной работы с DBUnit'ом. Но через аннотации нельзя сказать "проверь, что столбец заполнен хоть чем-нибудь" или "проверь, что время в колонке отстоит от текущего не более чем на 1 секунду", хотя сам DBUnit так умеет.

Управляем валидацией таблиц

DBUnit позволяет нам заранее описать, что мы хотим увидеть в базе. Но что если мы не знаем точно, что там будет? Автогенерация ID приводит к тому, что от порядка выполнения тестов зависят идентификаторы строк. Мы часто используем системное время, чтобы знать, когда была создана или обновлена запись. Все это приводит к невозможности полностью задать заранее состояние таблицы. Что с этим можно делать?

Исключение столбцов из валидации

Самый простой способ - утрать часть столбцов из валидации. Если мы не знаем, какой ID выдаст нам sequence, давайте его вообще не проверять. Это можно сделать двумя путями. Первый - не писать параметр id в XML-файле (работает с assertionMode=DatabaseAssertionMode.NON_STRICT*). Второй - использовать columnFilters (он тоже работает только с assertionMode=DatabaseAssertionMode.NON_STRICT*). Второй способ может пригодиться, например, если мы хотим использовать один и тот же файл и для начального наполнения БД и для верификации (или если вы фанат XSD-схем). Приведенный выше тестовый класс как раз реализует этот случай. В обоих тестах используется файл single-post.xml, в котором есть параметр id=1. Но при создании Post'а мы не знаем реальный сгенерированный ID и исключаем эту колонку при помощи IdColumnFilter. А когда мы тестируем скрытие Post'а, мы используем single-post.xml для начального заполнения БД, и там id=1 нам уже нужен, чтобы потом прятать запись по этому id.

Модификация данных

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

<dataset>
    <post id="[id]" author="tester" subject="test subject" text="test post body" published="true"/>
</dataset>

А потом говорим, что вместо [id] надо использовать реальный id, который мы получили. Для этого у ExpectedDataset есть параметр modifiers. Проблема в том, что его использовать максимально неудобно. modifiers ожидает в качестве параметра класс, а инстанциируется этот класс внутри Spring DBUnit. Класс должен реализовывать интерфейс с единственным методом:

DataSet modify(IDataSet dataSet);

Но как нам в этом классе использовать значение, полученное в тесте? Способ есть, но он такой себе. Spring DBUnit может передаватьв качестве modifier'а inner-класс теста. Не статический inner-класс, а тот, который может достучаться до параметров внешнего класса. То есть, рабочая схема примерно такая:

private PostDto post;

@Test
@ExpectedDatabase(value = "classpath:db/single-post.xml", table = "post", modifiers = IdModifier.class)
public void testCreatePost() {
   CreatePostRequestDto createPostRequest = new CreatePostRequestDto("tester", "test subject", "test post body");
   post = writeOperations.create(createPostRequest);
}

private class IdModifier implements DataSetModifier {
   @Override
   public IDataSet modify(IDataSet dataSet) {
      ReplacementDataSet replacement = new ReplacementDataSet(dataSet);
      replacement.addReplacementObject("[id]", WriteOperationsImplTest.this.post.id());
      return replacement;
   }
}

В тесте мы сохраняем данные в свойство post, которое читаем в IdModifier'е. Мы можем это сделать, потому что у IdModifier'а есть доступ к свойствам внешнего класса. Конечно, если действительно пользоваться таким подходом, то лучше сделать специальный класс, что-то типа DBUnitDataHolder, который будет позволять сохранять произвольные значения. Тест сохраняет данные, которые ему нужны и говорит, что будет использоваться конкретный DataSetModifier. А этот DataSetModifier, в свою очередь, знает о том, в каком формате тест для него должен положить данные. Если вы запускаете тесты в многопоточном режиме, то, возможно, для хранения результатов стоит также добавить ThreadLocal.

Сложная валидация

Предыдущий подход выглядит достаточно костыльно. Кроме того, могут быть ситуации, когда в тесте вы так и не узнаете, что же именно должно оказаться в базе. Например, если вы сохраняете время создания/изменения записи, но не возвращает это время наружу. Тогда у вас есть только слабые ограничения на ожидаемые значения в базе - вы знаете, что время должно быть примерно текущим. Отстающим не более чем на несколько секунд. Как я уже упоминал раньше, DBUnit умеет валидировать ограничения такого типа. Но spring-test-dbunit это не поддерживает. Чтобы воспользоваться такими проверками, придется немного поработать с DBUnit напрямую:

  @Autowired
  private IDatabaseConnection connection;

  @Test
  public void testCreatePost() throws Exception {
      CreatePostRequestDto createPostRequest = new CreatePostRequestDto("tester", "test subject", "test post body");
      PostDto post = writeOperations.create(createPostRequest);
      Assertions.assertTrue(post.id() > 0);
      Assertions.assertTrue(post.published());

      IDataSet actual = connection.createDataSet();
      IDataSet expected = new FlatXmlDataSetLoader().loadDataSet(getClass(), "classpath:db/single-post.xml");
      ValueComparer defaultValueComparer = ValueComparers.isActualEqualToExpected;
      Map<String, ValueComparer> columnValueComparers = new ColumnValueComparerMapBuilder()
          .add("id", ValueComparers.isActualGreaterThanOrEqualToExpected)
          .build();

      Assertion.assertWithValueComparer(
          expected.getTable("post"),
          actual.getTable("post"),
          defaultValueComparer,
          columnValueComparers
      );
  }

К счастью, spring-test-dbunit регистрирует свой IDatabaseConnection как бин в контексте, и мы легко можем получить IDataSet'ы для сравнения. В приведенном примере мы говорим, что дефолтный способ проверки - проверка на равенство, но для столбца id будет проверяться, что реальное значение больше или равно, чем значение в single-post.xml (1). Также DBUnit предоставляет удобные готовые компараторы для проверки, что время отстоит от ожидаемого не больше чем на одну секунду или одну минуту. Их удобно использовать с фичей DBUnit'а, позволяющей в датасетах указывать относительное время: [now] (текущее время), [now-1d] (вчера в тоже время) , [now-1d+30m] (вчера, но время на 30 минут позже, чем сейчас), [now-1d 10:00] (вчера в 10:00).

Заключение

На этом все, что я хотел рассказать. Коротко подытожу полезные возможности DBUnit:

  • Наполнение базы:

    • декларативное описание контента в XML и CSV;

    • поддержка бинарных данных;

    • поддержка относительного времени;

    • разные стратегии вставки и удаления данных как до теста, так и после.

  • Валидация базы:

    • поддержка разных уровней "строгости" валидации;

    • возможность исключения колонок из процедуры валидации;

    • поддержка модификации контента для валидации;

    • поддержка произвольной логики при сравнении данных.

Код проекта

Полный код доступен на GitHub

Теги:
Хабы:
Всего голосов 4: ↑2 и ↓2+1
Комментарии5

Публикации

Истории

Работа

Java разработчик
267 вакансий

Ближайшие события

25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань