Статьи

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

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

Если наше приложение использует Spring Framework, мы можем использовать Spring Test DbUnit и DbUnit для этой цели.

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

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

Дополнительное чтение:

  • Протестированное приложение описано в сообщении в блоге под названием « Весна из окопов: использование нулевых значений в наборах данных DbUnit» . Я рекомендую вам прочитать этот пост, потому что я не собираюсь повторять его содержание в этом посте.
  • Если вы не знаете, как написать интеграционные тесты для своих репозиториев, прочитайте мой пост в блоге под названием: Spring Data JPA Tutorial: Integration Testing . В нем объясняется, как можно писать интеграционные тесты для репозиториев Spring Data JPA, но вы можете использовать тот же подход, что и для написания тестов для других репозиториев на основе Spring, в которых используется реляционная база данных.

Мы не можем утверждать неизвестное

Начнем с написания двух интеграционных тестов для метода save () интерфейса CrudRepository . Эти тесты описаны ниже:

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

Оба теста инициализируют используемую базу данных, используя один и тот же набор данных DbUnit ( no-todo-records.xml ), который выглядит следующим образом:

1
2
3
<dataset>
    <todos/>
</dataset>

Исходный код нашего интеграционного тестового класса выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceContext.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)
public class ITTodoRepositoryTest {
 
    private static final Long ID = 2L;
    private static final String DESCRIPTION = "description";
    private static final String TITLE = "title";
    private static final long VERSION = 0L;
 
    @Autowired
    private TodoRepository repository;
 
    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-with-title-and-description-expected.xml")
    public void save_WithTitleAndDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(DESCRIPTION)
                .build();
 
        repository.save(todoEntry);
    }
 
    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-without-description-expected.xml")
    public void save_WithoutDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(null)
                .build();
 
        repository.save(todoEntry);
    }
}

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

Если вы хотите научиться писать хорошие интеграционные тесты для своего кода доступа к данным, вам следует прочитать мое руководство под названием: « Написание тестов для кода доступа к данным» .

Набор данных DbUnit ( save-todo-entry-with-title-and-description-Ожидаемый. XML), который используется для проверки того, что заголовок и описание сохраненного объекта Todo вставлены в таблицу задач , выглядит следующим образом:

1
2
3
<dataset>
    <todos id="1" description="description" title="title" version="0"/>
</dataset>

Набор данных DbUnit ( save-todo-entry-без-description -pected.xml), который используется для проверки того, что в таблицу задач добавляется только заголовок сохраненного объекта Todo , выглядит следующим образом:

1
2
3
<dataset>
    <todos id="1" description="[null]" title="title" version="0"/>
</dataset>

Когда мы запускаем наши интеграционные тесты, один из них не проходит, и мы видим следующее сообщение об ошибке:

1
2
3
junit.framework.ComparisonFailure: value (table=todos, row=0, col=id)
Expected :1
Actual   :2

Причина этого заключается в том, что столбец id таблицы todos является столбцом с автоинкрементом, а интеграционный тест, который вызывается первым, «получает» идентификатор 1. Когда вызывается второй интеграционный тест, значение 2 сохраняется в идентификаторе. столбец и тест не пройден.

Давайте выясним, как мы можем решить эту проблему.

Быстрые исправления для победы?

Есть два быстрых решения нашей проблемы. Эти исправления описаны ниже:

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

Конфигурация нашего тестового класса выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceContext.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class ITTodoRepositoryTest {
 
}

Это выглядит чисто, но, к сожалению, это может снизить производительность нашего набора интеграционных тестов, потому что он создает новый контекст приложения перед вызовом каждого метода тестирования. Вот почему мы не должны использовать аннотацию @DirtiesContext, если она НЕ АБСОЛЮТНО НЕОБХОДИМА .

Однако, если наше приложение имеет только небольшое количество интеграционных тестов, снижение производительности, вызванное аннотацией @DirtiesContext, может быть допустимым. Мы не должны отказываться от этого решения только потому, что оно замедляет наши тесты. Иногда это приемлемо, и если это так, то использование аннотации @DirtiesContext является хорошим решением.

Дополнительное чтение:

Во-вторых , мы могли бы опустить атрибут id элемента todos из наших наборов данных и установить значение атрибута assertionMode аннотации @ExpectedDatabase равным DatabaseAssertionMode.NON_STRICT . Это решило бы нашу проблему, потому что DatabaseAssertionMode.NON_STRICT означает, что столбцы и таблицы, которых нет в нашем файле набора данных, игнорируются.

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

Например, мы не можем использовать следующий набор данных:

1
2
3
4
<dataset>
    <todos id="1" description="description" title="title" version="0"/>
    <todos description="description two" title="title two" version="0"/>
</dataset>

Если мы используем DatabaseAssertionMode.NON_STRICT , каждая «строка» нашего набора данных должна указывать одинаковые столбцы. Другими словами, мы должны изменить наш набор данных, чтобы он выглядел так:

1
2
3
4
<dataset>
    <todos description="description" title="title" version="0"/>
    <todos description="description two" title="title two" version="0"/>
</dataset>

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

Однако, если каждая запись todo может иметь теги 0 .. *, у нас будут проблемы. Давайте предположим, что нам нужно написать интеграционный тест, который вставит две новые записи todo в базу данных и создаст набор данных DbUnit, который гарантирует, что

  • Запись todo под названием: «title one» имеет тег под названием «tag one»
  • Запись todo под названием: ‘title two’ содержит тег: ‘tag two’

Наши лучшие усилия выглядят следующим образом:

1
2
3
4
5
6
7
<dataset>
    <todos description="description" title="title one" version="0"/>
    <todos description="description two" title="title two" version="0"/>
     
    <tags name="tag one" version="0"/>
    <tags name="tag two" version="0"/>
</dataset>

Мы не можем создать полезный набор данных DbUnit, потому что мы не знаем идентификаторы записей todo, которые сохраняются в базе данных.

Мы должны найти лучшее решение.

В поисках лучшего решения

Мы уже нашли два разных решения нашей проблемы, но оба они создают новые проблемы. Существует третье решение, основанное на следующей идее:

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

Мы можем сделать это, выполнив следующие действия:

  1. Создайте класс, который используется для сброса столбцов с автоинкрементом указанных таблиц базы данных.
  2. Исправьте наши интеграционные тесты.

Давайте испачкаем руки.

Создание класса, который может сбрасывать столбцы с автоинкрементом

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

  1. Создайте конечный класс с именем DbTestUtil и предотвратите его создание, добавив в него приватный конструктор.
  2. Добавьте открытый статический метод void resetAutoIncrementColumns () в класс DbTestUtil . Этот метод принимает два параметра метода:
    1. Объект ApplicationContext содержит конфигурацию тестируемого приложения.
    2. Имена таблиц базы данных, столбцы автоинкремента которых должны быть сброшены.
  3. Реализуйте этот метод, выполнив следующие действия:
    1. Получить ссылку на объект DataSource .
    2. Прочитайте шаблон SQL из файла свойств ( application.properties ), используя ключ ‘test.reset.sql.template’.
    3. Откройте соединение с базой данных.
    4. Создайте вызванные операторы SQL и вызовите их.

Исходный код класса DbTestUtil выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
 
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
 
public final class DbTestUtil {
 
    private DbTestUtil() {}
 
    public static void resetAutoIncrementColumns(ApplicationContext applicationContext,
                                                 String... tableNames) throws SQLException {
        DataSource dataSource = applicationContext.getBean(DataSource.class);
        String resetSqlTemplate = getResetSqlTemplate(applicationContext);
        try (Connection dbConnection = dataSource.getConnection()) {
            //Create SQL statements that reset the auto increment columns and invoke
            //the created SQL statements.
            for (String resetSqlArgument: tableNames) {
                try (Statement statement = dbConnection.createStatement()) {
                    String resetSql = String.format(resetSqlTemplate, resetSqlArgument);
                    statement.execute(resetSql);
                }
            }
        }
    }
 
    private static String getResetSqlTemplate(ApplicationContext applicationContext) {
        //Read the SQL template from the properties file
        Environment environment = applicationContext.getBean(Environment.class);
        return environment.getRequiredProperty("test.reset.sql.template");
    }
}

Дополнительная информация:

Давайте продолжим и выясним, как мы можем использовать этот класс в наших интеграционных тестах.

Исправление наших интеграционных тестов

Мы можем исправить наши интеграционные тесты, выполнив следующие действия:

  1. Добавьте шаблон SQL сброса в файл свойств нашего примера приложения.
  2. Сбросьте столбец автоинкремента ( id ) таблицы задач перед вызовом наших методов тестирования.

Сначала мы должны добавить шаблон SQL сброса в файл свойств нашего примера приложения. Этот шаблон должен использовать формат, поддерживаемый методом format () класса String . Поскольку наше примерное приложение использует базу данных H2 в памяти, мы должны добавить следующий шаблон SQL в наш файл свойств:

1
test.reset.sql.template=ALTER TABLE %s ALTER COLUMN id RESTART WITH 1

Дополнительная информация:

Во-вторых , мы должны сбросить столбец ( id ) автоинкремента таблицы задач, прежде чем будут вызваны наши методы тестирования. Мы можем сделать это, внеся следующие изменения в класс ITTodoRepositoryTest :

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

Исходный код нашего фиксированного класса тестирования интеграции выглядит следующим образом (изменения выделены):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
 
import java.sql.SQLException;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {PersistenceContext.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(dataSetLoader = ColumnSensingReplacementDataSetLoader.class)
public class ITTodoRepositoryTest {
 
    private static final Long ID = 2L;
    private static final String DESCRIPTION = "description";
    private static final String TITLE = "title";
    private static final long VERSION = 0L;
 
    @Autowired
    private ApplicationContext applicationContext;
 
    @Autowired
    private TodoRepository repository;
 
    @Before
    public void setUp() throws SQLException {
        DbTestUtil.resetAutoIncrementColumns(applicationContext, "todos");
    }
 
    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-with-title-and-description-expected.xml")
    public void save_WithTitleAndDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(DESCRIPTION)
                .build();
 
        repository.save(todoEntry);
    }
 
    @Test
    @DatabaseSetup("no-todo-entries.xml")
    @ExpectedDatabase("save-todo-entry-without-description-expected.xml")
    public void save_WithoutDescription_ShouldSaveTodoEntryToDatabase() {
        Todo todoEntry = Todo.getBuilder()
                .title(TITLE)
                .description(null)
                .build();
 
        repository.save(todoEntry);
    }
}

Дополнительная информация:

Когда мы запускаем наши интеграционные тесты во второй раз, они проходят.

Давайте продолжим и подведем итог тому, что мы узнали из этого блога.

Резюме

Этот блог научил нас трем вещам:

  • Мы не можем написать полезные интеграционные тесты, если не знаем значений, которые вставляются в столбцы, значения которых генерируются автоматически.
  • Использование аннотации @DirtiesContext может быть хорошим выбором, если наше приложение не имеет много интеграционных тестов.
  • Если в нашем приложении много интеграционных тестов, мы должны сбросить столбцы с автоинкрементом перед вызовом каждого метода тестирования.

Вы можете получить пример приложения к этому сообщению в блоге от Github .