Pull to refresh

Интеграция DBUnit и Spring TestContext Framework

Reading time 8 min
Views 13K
С появлением в 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
Tags:
Hubs:
+6
Comments 13
Comments Comments 13

Articles