С появлением в Spring 2.5 фреймворка TestContext интеграционное тестирование кода, работающего с базой данных, существенно упростилось. Появились аннотации для декларативного указания контекста, в котором должен выполняться тест, аннотации для управления транзакциями в рамках теста, а также базовые классы тестов для JUnit и TestNG. В этой статье я опишу вариант интеграции фреймворка TestContext с DBUnit, позволяющим инициализировать базу данных и сверить её состояние с ожидаемым по окончании выполнения теста.
Рассмотрим простой пример: нам нужно протестировать корректное сохранение доменного объекта в базу.
DAO, отвечающий за сохранение объекта:
Стоит отметить, что интеграционное тестирование DAO подразумевает комплексное тестирование DAO, маппинга доменного объекта и перзистенс-провайдера. В нашем случае в качестве последнего используем Hibernate. Для тестирования создадим Spring-контекст testContext.xml следующего содержания:
Теперь создадим тестовый класс, расширив стандартный класс Spring TestContext Framework для транзакционных тестов на базе JUnit. Аннотация @ContextConfiguration указывает на контекст (располагающийся, в нашем случае, в classpath), в котором необходимо выполнять данный тест. Это позволяет нам инъектировать тестируемый DAO с помощью аннотации @Autowired.
Базовый класс AbstractTransactionalJUnit4SpringContextTests сконфигурирован таким образом, что каждый тестовый метод выполняется в транзакции, которая по окончании метода откатывается.
Далее необходимо проверить, что данные действительно сохранились в базу. Можно инъектировать в тестовый класс EntityManager и прямо после вставки данных использовать его для проверки соответствующих ассертов. В большинстве случаев при написании DAO этого будет вполне достаточно для контроля корректности маппингов и логики DAO. Транзакция по завершении теста откатится, и условие отсутствия побочных эффектов теста будет соблюдено.
Однако возникают ситуации, когда нам необходимо всё-таки подтвердить транзакцию по окончании теста, убедиться в её корректном завершении и проверить, что именно сохранилось в базу — вплоть до полей конкретных таблиц. Отмечу, что такие проверки, скорее всего, жёстко привяжут тест к физике базы данных и существенно повысят его чувствительность. Кроме того, он может не выполниться в связке с другим перзистенс-провайдером ввиду отличий в структуре или именовании таблиц.
Базовые классы тестов, предоставляемые Spring, предлагают лишь возможность выполнить некоторые SQL-скрипты над тестовой базой. Рассмотрим, как нам может помочь DBUnit, и как интегрировать его с Spring TestContext Framework.
DBUnit позволяет описывать состояние базы данных без привязки к физическим типам данных — в виде набора данных XML. Вот исходный набор данных для нашего теста: он пуст, в нём объявлена единственная таблица persons, соответствующая нашему доменному классу.
А вот ожидаемый набор данных: таблица persons здесь содержит три записи.
Следует сказать, что в DBUnit существует и сокращённый вариант записи, в котором имя таблицы описывается тегом, а значения полей — атрибутами. Но полноразмерный формат в некоторых случаях бывает функциональнее.
Создадим аннотацию для тестового метода, указывающую, какой набор данных необходимо загрузить перед началом метода (атрибут before), и с каким набором данных сверить базу после его завершения (атрибут after):
Для обработки этой аннотации расширим стандартный тестовый класс AbstractTransactionalJUnit4SpringContextTests.
Статический вложенный класс DbunitTestExecutionListener расширяет слушатель AbstractExecutionListener — часть фреймворка TestContext. Он включается в жизненный цикл теста с помощью аннотации @TestExecutionListeners на тестовом класе.
Тестовый класс подключается к жизненному циклу теста в двух точках. Первая — это метод DbunitTestExecutionListener#beforeTestMethod, выполняющийся перед каждым тестовым методом. В нём слушатель проверяет наличие на текущем тестовом методе нашей аннотации @DbunitDataSets. При наличии аннотации он производит инициализацию тестировщика базы данных из фреймворка DBUnit. Имя файла с набором данных, подлежащим загрузке в базу перед началом теста, получается из поля before аннотации @DbunitDataSets. Значение поля after аннотации и экземпляр тестировщика сохраняются в поля тестового класса.
Вторая точка входа — это метод assertAfterTransaction(), отмеченный аннотацией @AfterTransaction, также являющейся частью фреймворка TestContext. Эта аннотация обеспечивает выполнение метода по завершении транзакции каждого тестового метода, отмеченного аннотацией @Transactional. В этом методе мы используем ранее сохранённые databaseTester и afterDatasetFileName, а также стандартный функционал DBUnit, чтобы сравнить состояние базы данных с ожидаемым.
Посмотрим, как теперь будет выглядеть наш тест:
Аннотация 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
Рассмотрим простой пример: нам нужно протестировать корректное сохранение доменного объекта в базу.
@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