Пользователь
1,0
рейтинг
25 марта 2012 в 18:21

Разработка → Интеграция DBUnit и Spring TestContext Framework

JAVA*
С появлением в Spring 2.5 фреймворка TestContext интеграционное тестирование кода, работающего с базой данных, существенно упростилось. Появились аннотации для декларативного указания контекста, в котором должен выполняться тест, аннотации для управления транзакциями в рамках теста, а также базовые классы тестов для JUnit и TestNG. В этой статье я опишу вариант интеграции фреймворка TestContext с DBUnit, позволяющим инициализировать базу данных и сверить её состояние с ожидаемым по окончании выполнения теста.

Рассмотрим простой пример: нам нужно протестировать корректное сохранение доменного объекта в базу.
@Entity
public class Person {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    
    private String name;
...


DAO, отвечающий за сохранение объекта:

public class JpaPersonDao implements PersonDao {

    @PersistenceContext
    private EntityManager em;

    public void save(Person person) {
        em.persist(person);
    }

}


Стоит отметить, что интеграционное тестирование DAO подразумевает комплексное тестирование DAO, маппинга доменного объекта и перзистенс-провайдера. В нашем случае в качестве последнего используем Hibernate. Для тестирования создадим Spring-контекст testContext.xml следующего содержания:

    <!-- Для декларативного управления транзакциями с помощью @Transactional -->
    <tx:annotation-driven/>

    <!-- Встроенная тестовая база данных HSQLDB -->
    <jdbc:embedded-database id="dataSource" />

    <!-- перзистенс-модуль -->
    <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
        <property name="persistenceProviderClass" value="org.hibernate.ejb.HibernatePersistence"/>
        <property name="dataSource" ref="dataSource"/>
        <property name="packagesToScan" value="ru.kacit.commons.test.dbunit"/> <!-- пакет, в котором находятся доменные классы -->
        <property name="jpaPropertyMap">
            <map>
                <entry key="hibernate.show_sql" value="true"/>
                <entry key="hibernate.format_sql" value="true"/>
                <entry key="hibernate.hbm2ddl.auto" value="create"/>
            </map>
        </property>
    </bean>

    <!-- Типовой менеджер транзакций -->
    <bean class="org.springframework.orm.jpa.JpaTransactionManager" id="transactionManager">
        <property name="entityManagerFactory" ref="entityManagerFactory"/>
    </bean>
    
    <!-- Тестируемый DAO -->
    <bean class="ru.kacit.commons.test.dbunit.JpaPersonDao" />


Теперь создадим тестовый класс, расширив стандартный класс Spring TestContext Framework для транзакционных тестов на базе JUnit. Аннотация @ContextConfiguration указывает на контекст (располагающийся, в нашем случае, в classpath), в котором необходимо выполнять данный тест. Это позволяет нам инъектировать тестируемый DAO с помощью аннотации @Autowired.

@ContextConfiguration("classpath:testContext.xml")
public class JunitDbunitTest extends AbstractTransactionalJUnit4SpringContextTests {

    @Autowired
    public PersonDao personDao;

    @Test
    public void test1() {
        personDao.save(new Person("Чип"));
        personDao.save(new Person("Дейл"));
        personDao.save(new Person("Гаечка"));
    }
}


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

Далее необходимо проверить, что данные действительно сохранились в базу. Можно инъектировать в тестовый класс EntityManager и прямо после вставки данных использовать его для проверки соответствующих ассертов. В большинстве случаев при написании DAO этого будет вполне достаточно для контроля корректности маппингов и логики DAO. Транзакция по завершении теста откатится, и условие отсутствия побочных эффектов теста будет соблюдено.

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

Базовые классы тестов, предоставляемые Spring, предлагают лишь возможность выполнить некоторые SQL-скрипты над тестовой базой. Рассмотрим, как нам может помочь DBUnit, и как интегрировать его с Spring TestContext Framework.

DBUnit позволяет описывать состояние базы данных без привязки к физическим типам данных — в виде набора данных XML. Вот исходный набор данных для нашего теста: он пуст, в нём объявлена единственная таблица persons, соответствующая нашему доменному классу.

<!DOCTYPE dataset SYSTEM "dataset.dtd">
<dataset>
<table name="person">
    <column>id</column>
    <column>name</column>
</table>
</dataset>


А вот ожидаемый набор данных: таблица persons здесь содержит три записи.

<!DOCTYPE dataset SYSTEM "dataset.dtd">
<dataset>
    <table name="person">
        <column>id</column>
        <column>name</column>
        <row>
            <value>1</value>
            <value>Чип</value>
        </row>
        <row>
            <value>2</value>
            <value>Дейл</value>
        </row>
        <row>
            <value>3</value>
            <value>Гаечка</value>
        </row>
    </table>
</dataset>


Следует сказать, что в DBUnit существует и сокращённый вариант записи, в котором имя таблицы описывается тегом, а значения полей — атрибутами. Но полноразмерный формат в некоторых случаях бывает функциональнее.

Создадим аннотацию для тестового метода, указывающую, какой набор данных необходимо загрузить перед началом метода (атрибут before), и с каким набором данных сверить базу после его завершения (атрибут after):

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DbunitDataSets {

    String before();
    
    String after();
    
}


Для обработки этой аннотации расширим стандартный тестовый класс AbstractTransactionalJUnit4SpringContextTests.

@TestExecutionListeners(
        AbstractDbunitTransactionalJUnit4SpringContextTests.DbunitTestExecutionListener.class
)
public abstract class AbstractDbunitTransactionalJUnit4SpringContextTests
        extends AbstractTransactionalJUnit4SpringContextTests {

    /** Тестировщик DBUnit */
    private IDatabaseTester databaseTester;

    /** Имя файла с ожидаемым набором данных */
    private String afterDatasetFileName;

    /** Метод, выполняющийся по окончании транзакции тестового метода: сверка данных */
    @AfterTransaction
    public void assertAfterTransaction() throws Exception {
        if (databaseTester == null || afterDatasetFileName == null) {
            return;
        }
        IDataSet databaseDataSet = databaseTester.getConnection().createDataSet();
        IDataSet expectedDataSet = 
            new XmlDataSet(ClassLoader.getSystemResourceAsStream(afterDatasetFileName));
        Assertion.assertEquals(expectedDataSet, databaseDataSet);
        databaseTester.onTearDown();
    }

    private static class DbunitTestExecutionListener extends AbstractTestExecutionListener {

        /** Метод, выполняющийся перед запуском тестового метода: предустановка */
        public void beforeTestMethod(TestContext testContext) throws Exception {
            AbstractDbunitTransactionalJUnit4SpringContextTests testInstance = (AbstractDbunitTransactionalJUnit4SpringContextTests) testContext.getTestInstance();
            Method method = testContext.getTestMethod();

            DbunitDataSets annotation = method.getAnnotation(DbunitDataSets.class);
            if (annotation == null) {
                return;
            }

            DataSource dataSource = testContext.getApplicationContext().getBean(DataSource.class);
            IDatabaseTester databaseTester = new DataSourceDatabaseTester(dataSource);
            databaseTester.setDataSet(
                new XmlDataSet(ClassLoader.getSystemResourceAsStream(annotation.before())));
            databaseTester.onSetup();
            testInstance.databaseTester = databaseTester;
            testInstance.afterDatasetFileName = annotation.after();
        }
    }
}


Статический вложенный класс DbunitTestExecutionListener расширяет слушатель AbstractExecutionListener — часть фреймворка TestContext. Он включается в жизненный цикл теста с помощью аннотации @TestExecutionListeners на тестовом класе.

Тестовый класс подключается к жизненному циклу теста в двух точках. Первая — это метод DbunitTestExecutionListener#beforeTestMethod, выполняющийся перед каждым тестовым методом. В нём слушатель проверяет наличие на текущем тестовом методе нашей аннотации @DbunitDataSets. При наличии аннотации он производит инициализацию тестировщика базы данных из фреймворка DBUnit. Имя файла с набором данных, подлежащим загрузке в базу перед началом теста, получается из поля before аннотации @DbunitDataSets. Значение поля after аннотации и экземпляр тестировщика сохраняются в поля тестового класса.

Вторая точка входа — это метод assertAfterTransaction(), отмеченный аннотацией @AfterTransaction, также являющейся частью фреймворка TestContext. Эта аннотация обеспечивает выполнение метода по завершении транзакции каждого тестового метода, отмеченного аннотацией @Transactional. В этом методе мы используем ранее сохранённые databaseTester и afterDatasetFileName, а также стандартный функционал DBUnit, чтобы сравнить состояние базы данных с ожидаемым.

Посмотрим, как теперь будет выглядеть наш тест:

@ContextConfiguration("classpath:testContext.xml")
public class JunitDbunitTest extends AbstractDbunitTransactionalJUnit4SpringContextTests {

    @Autowired
    private PersonDao personDao;

    @Test
    @Rollback(false)
    @DbunitDataSets(before = "initialDataset.xml", after = "expectedDataset.xml")
    @DirtiesContext
    public void test1() {
        personDao.save(new Person("Чип"));
        personDao.save(new Person("Дейл"));
        personDao.save(new Person("Гаечка"));
    }
    
}


Аннотация Rollback(false) обеспечивает подтверждение транзакции по окончании теста. Аннотация @DirtiesContext указывает на необходимость пересоздания контекста Spring перед следующим тестом в классе. В нашей аннотации @DbunitDataSets мы указали имена файлов, содержащих начальный и ожидаемый наборы данных DBUnit.

Ограничением приведённого варианта тестирования является необходимость пересоздавать Spring-контекст перед каждым тестовым методом. По завершении теста в базе остаются не только рабочие данные (которые легко может удалить как DBUnit, так и метод AbstractTransactionalJUnit4SpringContextTests#deleteFromTables), но и вспомогательные таблицы и последовательности перзистенс-провайдера. Таким образом, каждый тестовый метод должен быть помечен аннотацией @DirtiesContext. В таком случае перед каждым тестовым методом будет заново создан контекст Spring и экспортирована схема базы данных.

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

В заключение хочу отметить, что абстрактный тест на базе TestNG пишется аналогично и ничем не отличается от теста на базе JUnit, кроме расширяемого базового класса — в данном случае это будет AbstractTransactionalTestNGSpringContextTests. Для своих целей я вынес слушатель DbunitTestExecutionListener в отдельный класс и реализовал два базовых класса для этих двух тестовых фреймворков.

Исходный код к статье выложен на GitHub: github.com/forketyfork/spring-dao-test-demo
Сергей Петунин @forketyfork
карма
30,7
рейтинг 1,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • 0
    отличная статья, спасибо!
  • 0
    Когда-то пробовал DB-unit.
    Из воспоминаний — желание иметь возможность исходные данные хранить не в виде XML, а в виде файла с командами SQL.
    • 0
      Да, насколько я знаю, такой возможности нет.
      Но XML-формат предоставляет всё-таки небольшую абстракцию над базой данных. В этом и преимущество DBUnit. Интеграционные тесты и так достаточно чувствительны, стоит ли привязывать их к SQL-синтаксису конкретной БД.
      • 0
        Почему бы там была привязка? Входной .sql-файл можно очевидным образом разбить на отдельные запросы и дальше эти инструкции через jdbc выполнить по-очереди. И это будет работать на всех dbms, более-менее поддерживающих стандартный sql.
        • 0
          Разные БД — разные диалекты, разные типы данных в маппингах. Из моего опыта, даже на простейших типах данных (boolean, например) уже начинаются проблемы несовместимости скриптов. Что уж говорить о каких-нибудь LOB'ах.
          • 0
            Какой маппинг? Какие диалекты? Тупой raw SQL statement execution на конкретном диалекте конкретной dbms.
            • 0
              Почему бы там была привязка? [...] будет работать на всех dbms, более-менее поддерживающих стандартный sql

              Тупой raw SQL statement execution на конкретном диалекте конкретной dbms.

              Обнаружены взаимоисключающие параграфы. Так всё-таки, стандартный SQL, или конкретный диалект конкретной DBMS?
              • 0
                У Вас детектор сломался. Поддержка более-менее стандартного SQL означает, что символ ';' используется для разделения инструкций и никак иначе.
                • 0
                  Хорошо. В таком случае, возвращаясь к вопросу, «почему бы там была привязка» — отвечаю, что привязка теста к конкретной БД будет заключаться именно в «конкретном диалекте конкретной DBMS», который заключается отнюдь не только в способе разделения инструкций.
                  • 0
                    Привязка конкретного теста — да. Привязка реализующей библиотеки — нет.
                    • 0
                      Интеграционные тесты и так достаточно чувствительны, стоит ли привязывать их к SQL-синтаксису конкретной БД.

                      Я вообще-то и говорил про привязку конкретных тестов.
                      Предлагаю закончить этот демагогический тред.
  • 0
    У нас тесты прокручиваются на hsqldb в памяти (настраивается отдельный юнит) вся схема БД генерится из юнита в @Before
    • 0
      По сути, у меня всё точно так же. Тег jdbc:embedded-database по умолчанию поднимает HSQLDB. Юнит для краткости кода настраивается прямо в entityManagerFactory, и его точно так же можно инъектировать с помощью @PersistenceUnit. Единственное отличие — в моём случае схема пересоздаётся автоматически за счёт присутствия аннотации @DirtiesContext.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.