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

    С появлением в 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
    Метки:
    Поделиться публикацией
    Комментарии 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.

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