Простой и декларативный способ выполнять sql запросы в JUnit тестах.

Введение

Структура JUnit теста следует модели тестового сценария (test case):

ПредУсловия (PreConditions) - это действия, которые переводят тестируемую систему в определённое состояние необходимое для выполнения тестового сценария.

Тестовый сценарий (Test case) - это действия, которые меняют состояние тестируемой системы с целью сверить действительное поведение системы с ожидаемым.

ПостУсловия (PostConditions) - это действия, которые переводят тестируемую систему в первоначальное состояние, которое было до выполнения ПредУсловий.

JUnit предоставляет соответствующие аннотации согласно модели тестового сценария:

  1. ПредУсловия (PreConditions) = @BeforeEach

  2. Тестовый сценарий (Test case) = @Test

  3. ПостУсловия (PostConditions) = @AfterEach

Пример структуры в Java коде:

public class SomeTest {

  @BeforeEach                // PreConditions
  void setUp() { ... }
  
  @Test                      // Test Case
  void testCase() { ... }
  
  @AfterEach                 // PostConditions
  void tearDown() { ... }
}

Представьте, что вам необходимо протестировать back-end приложение, которое подключается к системе управления базой данных (СУБД), например Postgresql. И вам необходимо вставить некоторые данные в СУБД до того как выполнить метод testCase():

public class SomeTest {

  @BeforeEach                // PreConditions
  void setUp() { 
    String sql = "insert into department(id, name) values(1, 'dep1');";
    // some code to execute sql query to database
  }

  @Test                      // Test Case
  void testCase() { ... }
  
  @AfterEach                 // PostConditions
  void tearDown() { 
    String sql = "delete from department where id = 1;";
    // some code to execute sql query to database
  }
}

В данном случае, разработчику, помимо кодирования самого теста, необходимо написать реализацию выполнения запроса в СУБД. И эта реализация должна быть переиспользуемая, т.к. выполнять запросы нужно в двух методах, помеченные аннотациями @BeforeEach и @AfterEach.

Такой подход имеет следующие недостатки:

  • требует дополнительных временных затрат на написание реализации выполнения SQL запросов в СУБД

  • требует протестировать новое решение по выполнению SQL запросов в СУБД

  • сложно переиспользовать решение в других проектах

Также существует ещё один недостаток, которые значительно усложняет предложенную выше реализацию. Давайте посмотрим на него...

В чём проблема?

Давайте добавим новый тест:

public class SomeTest {

  @BeforeEach                // PreConditions
  void setUp() { 
    String sql = "insert into department(id, name) values(1, 'dep1');";
    // some code to execute sql query to database
  }

  @Test                      // Test Case
  void testCase() { ... }

  @AfterEach                 // PostConditions
  void tearDown() { 
    String sql = "delete from department where id = 1;";
    // some code to execute sql query to database
  }

  @BeforeEach                // PreConditions
  void setUp2() { ... }

  @Test                      // Test Case
  void testCase2() { ... }

  @AfterEach                 // PostConditions
  void tearDown2() { ... }
}

Методы setUp() и setUp2() будут выполнены для обоих тестов testCase() и testCase2().

Почему?

Таков дизайн JUnit framework. В аннотациях @BeforeEach не предоставляется информация к какому тестовому методу он относится. Поэтому JUnit выполняет его для всех тестовых методов определённых в классе.

Примечание

Есть возможность использовать объект TestInfo. В этом случае внутри метода setUp() и setUp2() можно добавить if и выполнять код с SQL запросами в зависимости от названия метода.

Спасибо за уточнение @MaxDM1993

Как решить эту проблему?

JUnit "из коробки" предлагает только одну возможность: оградить каждый тестовый сценарий вложенным классом:

public class SomeTest {

  public static class TestCaseTest {
    
    @BeforeEach                // PreConditions
    void setUp() { 
       String sql = "insert into department(id, name) values(1, 'dep1');";
       // some code to execute sql query to data
    }
    
    @Test                      // Test Case
    void testCase() { ... }
    
    @AfterEach                 // PostConditions
    void tearDown() { 
      String sql = "delete from department where id = 1;";
      // some code to execute sql query to data
    }
  }

  public static class TestCase2Test {
    
    @BeforeEach                // PreConditions
    void setUp2() { ... }
    
    @Test                      // Test Case
    void testCase2() { ... }
    
    @AfterEach                 // PostConditions
    void tearDown2() { ... }
  }
}

Такое решение позволяет выполнять методы помеченные аннотациями @BeforeEach и @AfterEach только для определённого теста. Но такой подход привносить сложность в разработку и такой код сложно поддерживать и читать.

Есть ли другое решение?

Решение, которое предлагает JUnit "из коробки", требует от разработчика дополнительных усилий и временных затрат на реализацию механизма выполнения запросов на этапах ПредУсловий (PreConditions) и ПостУсловий (PostConditions).

Но существует удобный инструмент, который помогает легко решать задачи такого класса:

Цели проекта DbChange

  1. Предоставить API по удобному выполнению SQL запросов в JUnit тестах.

  2. Упростить написание и поддержку SQL запросов в JUnit тестах.

  3. Предоставить библиотеку, которая не зависит от различных фрейморков. (Используется только стандартная библиотека Java и JUnit 5 как compile зависимость)

Ключевые концепции DbChange

В DbChange есть три аннотации:

  1. DbChange

  2. DbChangeOnce

  3. SqlExecutorGetter

DbChange
Предоставляет информацию об изменениях в данных СУБД до/после выполнения определённого тестового метода.

DbChangeOnce
Предоставляет информацию об изменениях в данных СУБД до/после выполнения всех тестовых методов в классе.

SqlExecutorGetter
Задаёт sql executor по умолчанию для всех тестов в классе. Значение в этой аннотации является имя публичного метода в тестовом классе, который возвращает экземпляр класса, реализующего интерфейс SqlExecutor. DbChange предлагает одну реализация этого интерфейса - DefaultSqlExecutor.

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

@ExtendWith(DbChangeExtension.class)
@DbChangeOnce
@SqlExecutorGetter
public class DbChangeUsageTest {
    
    @Test
    @DbChange
    void test() {
    }
}

Подключение библиотеки в проект

Предусловия:

  1. Создать директорию libs в корне проекта.

  2. Скачать JAR файл последней версии DbChange cо страницы релизов проекта на Github.com.

  3. Скопировать скаченный JAR файл в директорию libs.

Gradle

  1. Открыть на редактирование файл build.gradle.kts (или build.gradle для groovy)

  2. Добавить flatDir в секцию repository (пример на Kotlin):

repository {
    flatDir{
        dirs('libs')
    }
}
  1. Добавить DbChange в зависимости проекта:

dependencies {
    testImplementation 'com.github.darrmirr:dbchange:1.0.0'
}
  1. Выполнить команду build проекта

Maven

  1. Открыть на редактирование pom.xml.

  2. Добавить DbChange в зависимости.

 <dependecy>
    <groupId>com.github.darrmirr</groupId>
    <artifactId>dbchange</artifactId>
    <version>1.0.0</version>
    <scope>test</scope>
  </dependecy>
  1. Добавить maven install plugin.

  <plugin>
     <groupId>org.apache.maven.plugins</groupId>
     <artifactId>maven-install-plugin</artifactId>
       <executions>
         <execution>
           <id>install-dbchange</id>
           <phase>generate-sources</phase>
           <goals>
             <goal>install-file</goal>
           </goals>
           <configuration>
             <file>${basedir}/libs/dbchange-1.0.0.jar</file>
           </configuration>
         </execution>
       </executions>
  </plugin>
  1. Выполнить команду mvn compile.

Как использовать DbChange

  1. (обязательно) Добавить @ExtendWith(DbChangeExtension.class) аннотацию над тестовым классом.

  2. (обязательно) Создать публичный метод в тестовом классе, который вернёт экземпляр класса, реализующий интерфейс SqlExecutor. Можно воспользоваться классом DefaultSqlExecutor.

  3. (опционально) Добавить аннотацию @DbChangeOnce над тестовым классом.

  4. (опционально) Добавить аннотацию @SqlExecutorGetter над тестовым классом.

  5. (опционально) Добавить аннотацию @DbChange над тестовым методом.

Примечание
  • DbChange не будет выполнять каких-либо действий, если в тестовом классе нет аннотаций @DbChangeOnce и @DbChange.

  • Если аннотация @SqlExecutorGetter не указана, то указание значения sqlExecuterGetter в аннотациях @DbChangeOnce и @DbChange – обязательно.

  • Если используется аннотация @DbChangeOnce, тогда необходимо инициировать экземпляр класса javax.sql.DataSource в конструкторе тестового класса или в статическом контексте (например, используя @BeforeAll JUnit аннотацию)

Простой пример использования DbChange:

@ExtendWith(DbChangeExtension.class)
@DbChangeOnce(sqlQueryFiles = "sql/database_init.sql")
@DbChangeOnce(sqlQueryFiles = "sql/database_destroy.sql", executionPhase = ExecutionPhase.AFTER_ALL)
@SqlExecutorGetter("defaultSqlExecutor")
public class DbChangeUsageTest {
    private DataSource dataSource;
    
    public DbChangeUsageTest() {
        dataSource = // code to create instance of DataSource 
    }
  
    public SqlExecutor defaultSqlExecutor() {
        return new DefaultSqlExecutor(dataSource);
    }
  
    @Test
    @DbChange(changeSet = InsertEmployee6Chained.class )
    @DbChange(changeSet = DeleteEmployee6Chained.class , executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void changeSetChained() {
        /* code omitted */
    }
}

Рабочий процесс DbChange

  1. Сборка информации из аннотаций @DbChangeOnce и @DbChange.

  2. Генерация SQL запросов с named JDBC параметрами.

  3. Передача сгенерированных SQL запросов на выполнение в SqlExecutor.

  4. Отправка через JDBC драйвер шаблона запроса и JDBC параметров в СУБД на выполнение.

В DbChange cуществует несколько источников изменений в СУБД. Эти источники называются "Поставщики SQL запросов"

Поставщики SQL запросов

DbChange выполняет SQL запросы, которые поставляются в аннотациях @DbChangeOnce и @DbChange.

Существуют следующие поставщики SQL запросов:

  • statements

  • sql query files

  • changeset

  • sql query getter

  • JUnit @MethodSource (только для параметризированных тестов в JUnit)

Примечание

Все поставщики SQL запросов (кроме @MethodSource) поддерживаются аннотациями @DbChangeOnce и @DbChange.

Рассмотрим каждого поставщика в отдельности.

Statements

Это значение в аннотации предоставляет возможность указать SQL запрос как строку:

@ExtendWith(DbChangeExtension.class)
public class ExampleTest {

    @Test
    @DbChange(statements = {
            "insert into department(id, name) values (14, 'dep14');",
            "insert into occupation(id, name) values (8, 'occ8');",
            "insert into employee(id, department_id, occupation_id, first_name, last_name) values (10, 14, 8, 'Ivan', 'Ivanov')"
    })
    @DbChange(statements = {
            "delete from employee where id = 10;",
            "delete from occupation where id = 8;",
            "delete from department where id = 14;"
    }, executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void statements() { /* code omitted */ }
 }

Плюсы:

  • Легко использовать

  • SQL запросы кодируются явно

  • Декларативный способ выполнения SQL запроса

Минусы:

  • Сложно переиспользовать SQL запросы в других тестах

  • Сложно читать Java код, если SQL запросов будет много или они будут содержать много переменных

  • Сложно кастомизировать такие SQL запросы параметрами

  • Сложно понять, какие именно значения нужны для конкретного теста, а какие вставляются для корректности выполнения SQL запроса (например, в силу not null ограничений)

  • Требуется много однотипных действий, если необходимо править все SQL запросы во всех классах

SQL query files

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

@ExtendWith(DbChangeExtension.class)
public class ExampleTest {

    @Test
    @DbChange(sqlQueryFiles = { "sql/test_1_init1.sql", "sql/test_1_init2.sql" })
    @DbChange(sqlQueryFiles = "sql/test_1_destroy_all.sql", executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void sqlQueryFiles() { /* code omitted */ }
}

Плюсы:

  • Легко использовать

  • Повышает читабельность кода, если используется большое количество SQL запросов

Минусы:

  • Сложно переиспользовать SQL запросы в других тестах

  • Сложно кастомизировать такие SQL запросы параметрами

  • Сложно понять, какие именно значения нужны для конкретного теста, а какие вставляются для корректности выполнения SQL запроса (например, в силу not null ограничений)

  • Требуется много однотипных действий, если необходимо править все SQL запросы во всех классах

Changeset

Это значение в аннотации предоставляет возможность указать массив классов, которые реализую интерфейс com.github.darrmirr.dbchange.sql.query.SqlQuery. Пример использования:

@ExtendWith(DbChangeExtension.class)
@SqlExecutorGetter("defaultSqlExecutor")
public class ExampleTest {

    @Test
    @DbChange(changeSet = { InsertDepartment9.class, InsertOccupation3.class, InsertEmployee5.class })
    @DbChange(changeSet = { DeleteEmployee5.class, DeleteOccupation3.class, DeleteDepartment9.class }, executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void changeSet() { /* code omitted */ }
}

Как это работает

Все классы, которые указываются в значении changeSet, обязаны реализовывать интерфейс com.github.darrmirr.dbchange.sql.query.SqlQuery:

import java.util.function.Supplier;

/**
 * Common interface for all sql query.
 */
@FunctionalInterface
public interface SqlQuery extends Supplier<String> {
}

Это довольно простой интерфейс, в котором определён только один метод get()(определён в интерфейсе Supplier). Этот метод возвращает SQL запрос в виде объекта Java String.

DbChange предоставляет несколько реализаций интерфейса SqlQuery:

  • TemplateSqlQuery

  • EmptyTemplateSqlQuery

  • InsertSqlQuery

  • SpecificTemplateSqlQuery

Все перечисленные классы предоставляют возможность задать SQL запрос с named JDBC параметрами. Такой подход даёт возможность переиспользовать ранее написанный код и кастомизировать SQL запрос.

Примечание

Рекомендуется использовать InsertSqlQuery и SpecificTemplateSqlQuery. Использование TemplateSqlQuery и EmptyTemplateSqlQuery не запрещено, но эти классы создавались преимущественно для внутреннего использования.

InsertSqlQuery

Этот класс расширяет TemplateSqlQuery класс и предоставляет возможность создать шаблон SQL запроса в зависимости от имён параметров и имени таблицы.

Класс InsertSqlQuery  абстрактный и вам необходимо расширить его, чтобы использовать в своём проекте:

public class InsertEmployee7 extends InsertSqlQuery {

    @Override
    public String tableName() {
        return "employee";
    }

    @Override
    public Map<String, Object> getParameters() {
        Map<String, Object> params = new HashMap<>();
        params.put("id", Id.EMP_7);
        params.put("department_id", Id.DEP_11);
        params.put("occupation_id", Id.OCC_5);
        return params;
    }
}

DbChange сгенерирует SQL запрос согласно данным класса InsertEmploee7 :

insert into employee(id, department_id, occupation_id) values (:id, :department_id, :occupation_id);

После этого DbChange продолжил работу по своему рабочему процессу, который был описан выше в этой статье.

SpecificTemplateSqlQuery

Этот класс также расширяет TemplateSqlQuery класс как и InsertSqlQuery. Вот только назначение у данного класса другое, а именно переиспользовать TemplateSqlQuery и переопределять его SQL параметры, которые нужны для конкретного теста.

public class InsertEmployee5 extends SpecificTemplateSqlQuery {

    @Override
    public TemplateSqlQuery commonTemplateSqlQuery() {
        return new InsertEmployeeCommon();
    }

    @Override
    public Map<String, Object> specificParameters() {
        Map<String, Object> params = new HashMap<>();
        params.put("id", 5);
        params.put("department_id", 9);
        params.put("occupation_id", 3);
        return params;
    }
}

Метод commonTemplateSqlQuery() возвращает экземпляр класса TemplateSqlQuery. Данный объект будет использоваться как основа для создания SQL запроса. Это означает, что DbChange возьмёт из него шаблон запроса и список named JDBC параметров. Но класс SpecificTemplateSqlQuery  предоставляет нам возможность переопределять эти JDBC параметры или добавлять новые. И метод specificParameters() как раз служит для этой цели.

Чтобы понять как это работает, посмотрим на класс, который возвращается commonTemplateSqlQuery() методом:

public class InsertEmployeeCommon extends TemplateSqlQuery {

    @Override
    public String queryTemplate() {
        return JdbcQueryTemplates.EMPLOYEE_INSERT;
    }

    @Override
    public Map<String, Object> getParameters() {
        Map<String, Object> params = new HashMap<>();
        params.put("id", null);
        params.put("department_id", null);
        params.put("occupation_id", null);
        params.put("first_name", "default_employee_first_name");
        params.put("last_name", "default_employee_last_name");
        return params;
    }
}

В InsertEmployeeCommon классе задано 5 параметров, но класс InsertEmployee5  переопределяет только 3 из них через метод specificParameters() .

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


Давайте подытожим плюсы и минусы использования changeset поставщика SQL запросов.

Плюсы:

  • Возможность переиспользовать код для генерации SQL запросов

  • Улучшенная поддержка кодовой базой по сравнению с текстовыми файлами или строками.

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

  • Возможность указать только необходимые для теста параметры, используя класс SpecificTemplateSqlQuery

  • Нет необходимость "зашивать" шаблон запроса в код. Класс InsertSqlQuery  сгенерирует его во время выполнения теста.

Минусы:

  • Требуется создавать отдельный файл с классом для каждого SQL запроса

  • Требуется наличия конструктора без аргументов

Sql query getter

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

Рассмотрим интерфейс SqlQueryGetter:

/**
 * Interface to supply @{@link List} of {@link SqlQuery} from method defined in test class.
 */
@FunctionalInterface
public interface SqlQueryGetter extends Supplier<List<SqlQuery>> {
}

Этот интерфейс поставляет список объектов, которые реализуют SqlQuery интерфейс. Посмотрим на использование этого интерфейса:

@ExtendWith(DbChangeExtension.class)
@SqlExecutorGetter("defaultSqlExecutor")
public class ExampleTest {

    @Test
    @DbChange(sqlQueryGetter = "testSqlQueryGetterInit")
    @DbChange(sqlQueryGetter = "testSqlQueryGetterDestroy", executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void sqlQueryGetter() { /* code omited */ }
    
    public SqlQueryGetter testSqlQueryGetterInit() { 
        return () -> Arrays.asList(
                () -> "insert into department(id, name) values(3, 'dep3');",
                TemplateSqlQuery
                    .templateBuilder(JdbcQueryTemplates.DEPARTMENT_INSERT)
                    .withParam(DepartmentQuery.PARAM_ID, Id.DEP_4)
                    .withParam(DepartmentQuery.PARAM_NAME, "dep" + Id.DEP_4)
                    .build()
        );
    }
    
    public SqlQueryGetter testSqlQueryGetterDestroy() { 
        return () -> Collections.singletonList(
                () -> String.format(JavaQueryTemplates.DEPARTMENT_DELETE_TWO, Id.DEP_3, Id.DEP_4)
        );
    }
}

Классы TemplateSqlQuery иInsertSqlQuery реализуют шаблон Builder. Это даёт возможность декларативно и просто создавать экземпляры этих классов без необходимости явно определять их в отдельных java файлах. В sqlQueryGetter вы можете использовать статические вложенные или анонимные классы и в них передавать зависимости. И наконец, вы можете использовать строки для задания SQL запросов.

Плюсы:

  • Включает все плюсы для changeset поставщика SQL запросов

  • Не требует использования конструктора без параметров

  • Не требует создания отдельного java файла для каждого SQL запроса

Минусы:

  • Требует создания дополнительных методов в тестовом классе

DbChange и параметризированные тесты

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

DbChange поддерживает только аннотацию @MethodSource как источник SQL запросов. Рассмотрим, пример:

@ExtendWith(DbChangeExtension.class)
@SqlExecutorGetter("defaultSqlExecutor")
public class ExampleTest {

    @ParameterizedTest
    @MethodSource("sourceStatementsParameterized")
    void statementsParameterized(List<DbChangeMeta> dbChangeMetas) {
        // code omitted
    }

    public static Stream<Arguments> sourceStatementsParameterized() {
        return Stream.of(
                Arguments.of(
                        Arrays.asList(
                                new DbChangeMeta()
                                        .setStatements(Arrays.asList(
                                                "insert into department(id, name) values (15, 'dep15');",
                                                "insert into occupation(id, name) values (9, 'occ9');",
                                                "insert into employee(id, department_id, occupation_id, first_name, last_name) values (11, 15, 9, 'Ivan', 'Ivanov')"
                                        ))
                                        .setExecutionPhase(DbChangeMeta.ExecutionPhase.BEFORE_TEST),
                                new DbChangeMeta()
                                        .setStatements(Arrays.asList(
                                                "delete from employee where id = 11;",
                                                "delete from occupation where id = 9;",
                                                "delete from department where id = 15"
                                        ))
                                        .setExecutionPhase(DbChangeMeta.ExecutionPhase.AFTER_TEST)
                        )
                ),
                Arguments.of(
                        Arrays.asList(
                                new DbChangeMeta()
                                        .setStatements(Arrays.asList(
                                                "insert into department(id, name) values (16, 'dep16');",
                                                "insert into occupation(id, name) values (10, 'occ10');",
                                                "insert into employee(id, department_id, occupation_id, first_name, last_name) values (12, 16, 10, 'Petr', 'Petrov')"
                                        ))
                                        .setExecutionPhase(DbChangeMeta.ExecutionPhase.BEFORE_TEST),
                                new DbChangeMeta()
                                        .setStatements(Arrays.asList(
                                                "delete from employee where id = 12;",
                                                "delete from occupation where id = 10;",
                                                "delete from department where id = 16;"
                                        ))
                                        .setExecutionPhase(DbChangeMeta.ExecutionPhase.AFTER_TEST)
                        )
                )
        );
    }
}

Во-первых, обратите внимание, что аннотация @DbChange не используется в параметризованных тестах. Вы можете поставить эту аннотацию над методом, но SQL запросы из неё будут выполняться для каждого набора аргументов в параметризованном тесте.

Во-вторых, вы обязаны указать List<DbChangeMeta> dbChangeMetas в аргументах тестового метода. Это обязательно, так требует внутренняя реализация JUnit.

Что такое DbChangeMeta?

DbChangeMeta - это класс в DbChange JUnit расширении. Во время работы DbChange конвертирует всю информацию из аннотаций @DbChange и @DbChangeOnce в экземпляры класса DbChangeMeta. Это происходит на первом шаге рабочего процесса DbChange. В подавляющем большинстве случаев разработчик, использующий DbChange, работает только с аннотациями @DbChange и @DbChangeOnce. Но существует одно исключение из этого правила - это параметризованный тест.

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

Класс DbChangeMeta  имеет туже структуру, что и аннотации @DbChange и @DbChangeOnce. И все правила использования поставщиками SQL запросов справедливы и для класса DbChangeMeta.

Связанные (chained) SQL запросы

Рассмотрим пример:

@ExtendWith(DbChangeExtension.class)
@SqlExecutorGetter("defaultSqlExecutor")
public class ExampleTest {

    @Test
    @DbChange(changeSet = { InsertDepartment9.class, InsertOccupation3.class, InsertEmployee5.class })
    @DbChange(changeSet = { DeleteEmployee5.class, DeleteOccupation3.class, DeleteDepartment9.class }, executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void changeSet() { /* code omitted */ }
}

Из данного кода, к сожалению, не очевидно, что InsertEmployee5.class зависит от InsertOccupation3.class иInsertDepartment9.class . И если изменить порядок в массиве, например поставить InsertEmployee5.class в самое начало, то выполнение теста завершится брошенным исключением. Причина ошибка заключается в том, что при попытке вставить новую запись в таблицу employee, СУБД вернёт ошибку, что департамент и профессия для данного сотрудника не найдены в соответствующих таблицах. А отсутствуют они из-за не корректного порядка выполнения SQL запросов.

DbChange предоставляет возможность связать (chain) такие запросы в цепочку и выполнять их в нужной последовательности. Рассмотрим интерфейс, позволяющий выполнить такое связывание:

@FunctionalInterface
public interface ChainedSqlQuery {

    /**
     * Get next instance of {@link SqlQuery} that relates to current one.
     *
     * @return instance of {@link SqlQuery}.
     */
    SqlQuery next();
}
Примечание

Такая возможность доступна только для changeset и sqlQueryGetter поставщиков SQL запросов.

Интерфейс ChainedSqlQuery довольно простой. У него только один метод next(). Рассмотрим, пример использования интерфейса:

public class InsertEmployee5Chained extends SpecificTemplateSqlQuery implements ChainedSqlQuery {

    @Override
    public TemplateSqlQuery commonTemplateSqlQuery() {
        return new InsertDepartmentCommon();
    }

    @Override
    public Map<String, Object> specificParameters() {
        return Collections.singletonMap(DepartmentQuery.PARAM_ID, Id.DEP_9);
    }

    @Override
    public SqlQuery next() {
        return new InsertOccupation3();
    }

    public static class InsertOccupation3 extends SpecificTemplateSqlQuery implements ChainedSqlQuery  {

        @Override
        public TemplateSqlQuery commonTemplateSqlQuery() {
            return new InsertOccupationCommon();
        }

        @Override
        public Map<String, Object> specificParameters() {
            return Collections.singletonMap("id", Id.OCC_3);
        }

        @Override
        public SqlQuery next() {
            return new InsertEmployee5();
        }
    }

    public static class InsertEmployee5 extends SpecificTemplateSqlQuery {
        @Override
        public TemplateSqlQuery commonTemplateSqlQuery() {
            return new InsertEmployeeCommon();
        }

        @Override
        public Map<String, Object> specificParameters() {
            Map<String, Object> params = new HashMap<>();
            params.put("id", Id.EMP_5);
            params.put("department_id", Id.DEP_9);
            params.put("occupation_id", Id.OCC_3);
            return params;
        }
    }
}

Здесь довольно много строчек кода, рассмотрим их более подробно.

Класс InsertEmployee5Chained расширяет SpecificTemplateSqlQuery и переиспользует SQL запрос, определённый в классе InsertDepartmentCommon. Дополнительно InsertEmployee5Chained переопределяет некоторые JDBC параметры в запросе на вставку данных в таблицу с департаментами.

Возможно, это выглядит странным, что имя класса говорит о вставке данных по сотруднику, а в действительности класс содержит информацию для SQL запроса на вставку данных по департаменту. Во-первых, согласно бизнес модели примера, нельзя вставить данные по сотруднику без данных по департаменту, которому данный сотрудник принадлежит. Во-вторых, не стоит забывать, что определение класса - это не только его методы и переменные. У класса ещё могут быть вложенные классы. И в приведённом примере их два: InsertOccupation3 и InsertEmployee5.

Как DbChange поймёт, в какой последовательности выполнять SQL запросы, определённые в этих классах?

Вот здесь в дело вступает ChainedSqlQuery интерфейс. Его метод next() указывает на следующий выполняемый SQL запрос. В приведённом примере - это InsertOccupation3.

Заметьте, что класс InsertOccupation3 тоже реализует ChainedSqlQuery интерфейс. Где указывается, что следующий выполняемый SQL запрос - InsertEmployee5.

Таким образом, цепочка состоит из 3-х SQL запросов:

insert department 9 -> insert occupation 3 -> insert employee 5

Примечание

Вы можете связывать столько SQL запросов, сколько вам необходимо. Размер цепочки ограничен только размером стека потока, который определён в вашей виртуальной машине Java (JVM)

И наконец, внесём изменения в аннотацию @DbChange в примере с которого мы начинали рассматривать связанные SQL запросы:

@ExtendWith(DbChangeExtension.class)
@SqlExecutorGetter("defaultSqlExecutor")
public class ExampleTest {

    @Test
    @DbChange(changeSet = InsertEmployee5Chained.class )
    @DbChange(changeSet = DeleteEmployee5Chained.class, executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void changeSet() { /* code omitted */ }
}

Как вы видите, количество классов в массиве changeSet уменьшилось с 3-х до одного. И теперь chained классы содержат необходимую цепочку SQL запросов для выполнения их в требуемом порядке.

DbChange фазы выполнения

Возможно вы обратили внимание на значения executionPhase в аннотациях @DbChange или@DbChangeOnce.

Фаза выполнения описывает момент времени в процессе прогона теста, в который необходимо выполнить SQL запрос. Фазы выполнения, определённые в DbChange, полностью совпадают с фазами, определёнными в JUnit.

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

SqlExecutorGetter

Обычно приложение использует только один экземпляр класса javax.sql.DataSource для подключения к СУБД. Но иногда приложение работает с несколькими схемами или с несколькими БД одновременно. И по этой причине в приложении может быть проинициализировано несколько экземпляров класса javax.sql.DataSource.

DbChange предоставляет возможность указать SqlExecutor в аннотации @DbChange и@DbChangeOnce. Для этой цели используется значение sqlExecutorGetter. В этом значении необходимо указать имя публичного метода, определённого в тестовом классе. Этот метод должен возвращать экземпляр класса, который реализует интерфейс com.github.darrmirr.dbchange.sql.executor.SqlExecutor.

Рассмотрим пример:

@ExtendWith(DbChangeExtension.class)
@SqlExecutorGetter("defaultSqlExecutor")
public class DbChangeUsageTest {
    private DataSource dataSource1;
    private DataSource dataSource2;
    
    public DbChangeUsageTest() {
        dataSource1 = // code to create instance of DataSource
        dataSource2 = // code to create instance of DataSource 
    }
  
    public SqlExecutor defaultSqlExecutor() {
        return new DefaultSqlExecutor(dataSource1);
    }
 
    public SqlExecutor datasource2SqlExecutor() {
        return new DefaultSqlExecutor(dataSource2);
    } 
  
    @Test
    @DbChange(changeSet = InsertEmployee6Chained.class)
    @DbChange(changeSet = DeleteEmployee6Chained.class , executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    @DbChange(changeSet = InsertBankList.class, sqlExecutorGetter = "datasource2SqlExecutor")
    @DbChange(changeSet = DeleteBankList.class, sqlExecutorGetter = "datasource2SqlExecutor", executionPhase = DbChange.ExecutionPhase.AFTER_TEST)
    void test() {
        /* code omitted */
    }
}

DbChange возьмёт экземпляр класса, реализующий интерфейс SqlExecutor, из метода datasource2SqlExecutor для выполнения запросов InsertBankList и DeleteBankList. Значение sqlExecutorGetter  в аннотациях @DbChange или@DbChangeOnce всегда переопределяет значение в аннотации @SqlExecutorGetter.

Примечание

Если аннотация @SqlExecutorGetter не определена в тестовом классе, то указание значения в sqlExecutorGetter в каждой аннотации @DbChange и@DbChangeOnce - обязательно.


Заключение

DbChange является расширением JUnit 5, которое предоставляет возможность декларативно указать SQL запросы и выполнить их на стадиях ПредУсловия (PreCondition) и ПостУсловия (PostCondition).

DbChange репозиторий доступен на Github.com.

Примеры использования расширения можно посмотреть в классе com.github.darrmirr.dbchange.component.DbChangeUsageTest в кодовой базе проекта.

Комментарии (3)


  1. MaxDM1993
    26.08.2022 13:55
    +3

    Таков дизайн JUnit framework. В аннотациях @BeforeEach не предоставляется информация к какому тестовому методу он относится. Поэтому JUnit выполняет его для всех тестовых методов определённых в классе.

    Ну тут автор несколько лукавит. Junit5 предоставляет возможность подставлять в предУсловие (@BeforeEach) и в постУсловие (@BeforeAll) объект TestInfo, в котором как видно из названия, хранится информация о тесте. Пример:
    @BeforeEach
    public void precondition(TestInfo testInfo) { ... }
    

    И в документации об этом сказано.


    1. VladimirPolukeev Автор
      26.08.2022 20:25

      Спасибо за ваш комментарий. Дополню статью об использовании TestInfo.


  1. IvanVakhrushev
    29.08.2022 10:02

    Скопировать скаченный JAR файл в директорию libs.

    Какой-то колхоз!) Завёл https://github.com/DarrMirr/dbchange/issues/1

    А за статью большое спасибо; взял себе на заметку.