Пишем и тестируем код, работающий с БД, вместе с DBUnit и LiquiBase. Часть 2
Я продолжаю рассказ об двух полезных для любого java (а может и не только) программиста или тестировщика утилитах: dbunit и liquibase. В прошлый раз я начал рассказ о том, как с помощью dbunit можно создать тестовый набор данных в формате xml. Сегодня же нам нужно разобраться с тем, как импортировать эти данные в БД при запуске тестов и то, как интегрировать dbUnit и jUnit.
Однако перед этим я сделаю небольшое отступление и закрою вопрос экспорта данных. Хотя мы рассмотрели все хитрости экспорта данных в формат xml, это не решает проблему неудобства подготовки такого набора данных: редактировать большие xml-документы не удобно, даже при наличии таких специализированных редакторов как xmlspy. Гораздо приятнее, если данные можно готовить в ms excel или csv. Нет проблем, dbUnit поддерживает экспорт данных из БД в эти два формата, например, так:
// создаем объект DataSet с правилами "что нужно экспортировать"
IDataSet allDataSet = iConnection.createDataSet();
// записываем эти данные в файл csv
CsvDataSetWriter.write(allDataSet, new File("allcsv-dir"));
// а теперь в файл excel
XlsDataSet.write(allDataSet, new FileOutputStream("all.xls"));
Небольшие замечания: для того чтобы импорт в excel корректно работал, dbunit-у потребуется еще одна библиотека – poi (домашний сайт проекта
http://jakarta.apache.org/poi/). В случае если мы экспортируем несколько таблиц, то каждая из них будет представлена отдельных excel-листом (название листа равно имени таблицы). А в случае использования csv, будет создан каталог (в примере “allcsv-dir”) внутрь которого будут помещены csv файлы для каждой из таблиц (users.csv, purchases.csv, articles.csv).
Теперь перейдем к написанию junit-тестов. Я использую junit4, хотя можно использовать и junit3 или testng: отличия минимальны. Начну с того, что создам класс TestA и объявлю в его составе статическое поле:
private static IDatabaseTester tester = null;
Его назначение – хранить ссылку на инфраструктуру dbUnit. Именно с помощью объекта IDatabaseTester я должен будут подключиться к серверу БД и именно внутри IDatabaseTester находятся методы импорта данных в БД. Теперь создаю метод setUpClass помеченный аннотацией “BeforeClass”. Напоминаю, что эта аннотация гарантирует однократный вызов метода, перед тем как будут запущены все методы-тесты в составе класса:
@BeforeClass
public static void setUpClass() throws Exception {
tester = new JdbcDatabaseTester("com.mysql.jdbc.Driver",
"jdbc:mysql://localhost/dbutest?useUnicode=true&characterSet=UTF-8", "user", "pass");
tester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
tester.setTearDownOperation(DatabaseOperation.NONE);
}
Обратите внимание на то, что для присвоения переменной tester создается объект JdbcDatabaseTester, входными параметрами конструктора для которого являются имя драйвера к СУБД, jdbc url и учетные данные. В случае если подключение выполняется к jndi-источнику данных, то нужно сделать так:
tester = new JndiDatabaseTester("java:comp/env/jdbc/DataSource");
В том случае, если вы получаете подключение к БД откуда-то извне (например, из spring), то пригодится класс DataSourceDatabaseTester, в качестве параметра его конструктора передается объект DataSource. В том случае, если параметры подключения находятся внутри “System.properties”, то можно использовать класс PropertiesBasedJdbcDatabaseTester.
После создания объекта tester, его нужно настроить. Настройка включает указание двух операций setUp и tearDown, соответственно, выполняющихся при запуске очередного теста и после его завершения. Перед началом теста у меня будет срабатывать операция CLEAN_INSERT, т.е. содержимое таблиц будет очищено, а затем заполнено начисто. На событие tearDown я никаких действий не выполняю (NONE). Теперь нужно создать еще два метода и пометить их аннотациями @Before и @After – они будут “окружать” запуск каждого из тестов:
@Before
public void setUp() throws Exception {
// загружаем набор с тестовыми данными
IDataSet dataSet = new FlatXmlDataSet(
new InputStreamReader(new FileInputStream("all-tables-dataset.xml"),
"utf-8"
));
tester.setDataSet(dataSet);
tester.onSetup();
}
@After
public void tearDown () throws Exception {
tester.onTearDown();
}
Действия внутри метода setUp тривиальны: прочитать содержимое файла "all-tables-dataset.xml", создать на его основе объект XmlDataSet, импортировать его внутрь tester-а и запустить операцию подготовки БД к тесту. Теперь привожу код самого тестируемого метода:
@Test
public void testSelect() throws Exception {
// получаем ссылку на соединение с БД
Connection con = tester.getConnection().getConnection();
// выполняем запрос на поиск некоторой записи
ResultSet rs = con.createStatement().executeQuery("select * from users where id_user = 1");
// проверяем, что запись была найдена
Assert.assertTrue(rs.next());
Assert.assertEquals(rs.getString("fio"), "Not-a-Jim");
}
Теперь краткий анализ показанного кода. В целом он … пусть не ужасен, но и красивым не назовешь. Во-первых, обычно в составе тестируемого класса не один, а несколько методов помечены “@Test”, значит, что перед вызовом каждого из них будет срабатывать @Before, который загружает из xml-файла данные и импортирует их в БД. Очевидно, что первый шаг улучшения – вынести операцию чтения xml-набора данных в метод инициализации всего класса “@BeforeClass”. Во-вторых: объем xml-данных может быть очень велик и процедура “удалить все, затем заполнить наново” будет занимать много времени. Решением проблемы мог бы быть режим выполнения операции setUp равный REFRESH, например, так:
tester.setSetUpOperation(DatabaseOperation.REFRESH);
К сожалению, если посмотреть журнал посылаемых на сервер sql-запросов, то можно увидеть, что не все так гладко. Для примера я создал три записи в таблице users, затем создал xml-снимок данных. После чего одна из этих трех записей была удалена, а еще одна была добавлена. После выполнения REFRESH я получил в журнале выполненных действий следующие шаги: три команды UPDATE, каждая из которых обновляет хранящуюся в БД запись до “как бы актуального” состояния. Один из трех update-ов завершился неудачно (действительно, я ведь удалил одну из записей) и это инициировало операцию вставки. Что касается “лишней” записи, то она осталась без изменений (не была удалена). Одним словом, если вам нужно гарантированное окружение на момент начала теста, то нужно использовать CLEAN_INSERT. Если вас заинтересовал вопрос о том, как узнать какие sql-команды посылаются на сервер, то лучше всего будет обратиться к документации по вашему jdbc-драйверу. Например, если я использую mysql, то для журналирования выполняемых sql-команд, мне достаточно указать переменную profileSQL при подключении к СУБД:
jdbc:mysql://localhost/база-данных?profileSQL=true
Некоторым способом улучшения производительности мог бы стать прием с разбиением одного огромного xml-файла с набором тестовых данных на несколько узкоспециализированных. Грубо говоря, каждому из тестовых методов testA, testB … ставился в соответствие и импортировался только один файл testA.xml, testB.xml. К сожалению, в jUnit нет способа внутри обработчика setUp узнать то, какой из тест-методов он предваряет. Решением может быть использование параметризированных запросов, например, так:
@RunWith(Parameterized.class)
public class TestA {
@Parameterized.Parameters
public static List<Object[]> parameters() {
return Arrays.asList(
new Object[][]{
{"fragment-a.xml", "methodA"},
{"fragment-b.xml", "methodB"}
}
);
}
String xmlFragmentName;
String methodName;
public TestA(String xmlFragmentName, String methodName) {
this.xmlFragmentName = xmlFragmentName;
this.methodName = methodName;
}
@Test
public void unifiedTest() throws Exception {
// загружаем набор с тестовыми данными
IDataSet dataSet = new FlatXmlDataSet(
new InputStreamReader(new FileInputStream(xmlFragmentName), "utf-8")
);
tester.setDataSet(dataSet);
tester.onSetup();
// а теперь выполняем метод с заданным именем
getClass().getMethod(methodName).invoke(this);
}
public void methodA() throws Exception {
}
public void methodB() throws Exception {
}
// все как ранее
Я создал метод parameters, который формирует список пар “имя xml-файла и имя тестируемого метода”. Затем внутри класса TestA, я пометил аннотацией @Test только один метод (сами же тестируемые методы methodA, methodB никаких дополнительных маркировок не имеют). Код метода unifiedTest очень прост: вначале выполняется чтение xml-файла с фрагментом данных и после их импорта в БД, запускается с помощью invoke тестируемый метод. Такой прием решает проблему скорости тестирования, но добавляет новую – неудобство отображения сведений о том, какой метод (methodA, methodB) был провален в ходе тестирования.
Давайте еще раз посмотрим на приведенный выше фрагмент кода и попробуем найти, что же еще в нем не идеально? Первым кандидатом на улучшение выглядит код проверки того, что внесенные изменения в БД правильны. Я делаю ужасный код на древнем jdbc, который обращается к БД, затем перемещение на нужную запись с помощью next и, апофеоз проверки – getString и сравнение поля fio с явно заданным в коде значением “Not-a-Jim”. Код доступа к данным, конечно, может и должен быть переписан с использованием более современных средств: hibernate, ibatis. Однако это не решает проблему “храним, что должно быть в БД, явно в коде теста”. Логичным шагом при использовании dbUnit, было бы хранить “снимки” идеального состояния БД также во внешнем xml-файле. И после того как отработает ваш тестируемый код, мы могли бы попросить dbUnit сравнить текущее состояние БД с эталонным. И dbUnit умеет это делать. Вот пример кода обновленного метода тестирования:
@Test
public void testSelect() throws Exception {
// получаем ссылку на соединение с БД
Connection con = tester.getConnection().getConnection();
// выполняем запрос на модификацию данных
con.createStatement().executeUpdate("update users set sex= 'f' where id_user = 1");
// проверяем что состояние БД правильное
// получаем из БД ее актуальное состояние
IDataSet databaseDataSet = tester.getConnection().createDataSet();
ITable actualTable = databaseDataSet.getTable("users");
// загружаем из внешнего xml-файла идеальное состояние
IDataSet expectedDataSet = new FlatXmlDataSet(new File("ideal.xml"));
ITable expectedTable = expectedDataSet.getTable("users");
// сравниваем эти два состояния между собой
Assertion.assertEquals(expectedTable, actualTable); }
Теперь краткий анализ кода. Вся магия скрыта в вызове Assertion.assertEquals. В качестве параметра этому методу нужно передать два объекта ITable, один из которых представляет реальное состояние в БД после модификации данных (так я изменил одному из сотрудников пол на “f”). Второй же объект ITable был загружен из xml-файла с данными (ideal.xml). Класс Assertion имеет еще одну перегруженную версию метода assertEquals, которая умеет сравнивать не отдельные таблицы, а целые наборы данных (IDataSet). Казалось бы, что еще можно пожелать от dbUnit-а? Ох, но многое. Во-первых, хороший программист сразу задумается, а что скрывается за магией assertEquals и как именно выполняется это самое сравнение данных между собой? Начнем с того, что разберемся с тем, как выполнить сравнение не “всей таблицы целиком”, а отдельных ее фрагментов. Прежде всего, мы можем создать объект “реальной ITable”, например, так:
ITable actualTable = tester.getConnection().createQueryTable(
"users", "select * from users where id_user < 10"
);
Здесь я хочу сделать снимок для последующего сравнения таблицы “users”, но лишь той ее части, которая удовлетворяет условию “id_user < 10”. Теперь, я хочу при сравнении содержимого таблиц указать, что некоторые из полей не существенны:
ITable preActualTable = databaseDataSet.getTable("users");
ITable actualTable = DefaultColumnFilter.excludedColumnsTable(
preActualTable, new String[]{"sex"}
);
IDataSet expectedDataSet = new FlatXmlDataSet(new File("ideal.xml"));
ITable preExpectedTable = expectedDataSet.getTable("users");
ITable expectedTable = DefaultColumnFilter.excludedColumnsTable(
preExpectedTable, new String[] {"sex"}
);
Assertion.assertEquals(expectedTable, actualTable);
В коде я должен был сделать два шага: первый, как и раньше, это создать два объекта ITable на основании xml-набора данных и содержимого БД. Второй же шаг – создать еще один объект ITable с помощью вызова excludedColumnsTable. В качестве параметров этому методу передается объект-шаблон ITable и список имен колонок, которые нужно исключить из сравнения. Есть и похожий метод includedColumnsTable, который выполняет обратную работу – явно задает имена колонок, по которым должно вестись сравнение. На этом я завершаю рассказ о возможностях dbUnit и настоятельно рекомендую попробовать его “в деле”: скорость разработки существенно вырастает и появляется чувство уверенности в том, “что бы я не делал с БД, всего можно узнать правильны ли мои правки”.
Вторая часть статьи будет посвящена LiquiBase. Напомню, что назначение этого продукта получить больше контроля над изменениями, которые вы делаете с БД, в ходе развития (эволюции) создаваемой вами программы. Я рекомендую использовать, именно, LiquiBase, а не связываться с текстовыми файлами, чтобы помечать в них “когда и какие поля в БД были изменены”, и что нужно сделать на сервере БД заказчика, чтобы обновление версии программы прошло без проблем. Итак, домашний сайт проекта
http://www.liquibase.org/. Скачав и распаковав архив, вы получите исполняемый файл liquibase.bat. Запуская его с разными параметрами командной строки, мы можем выполнять различные действия над СУБД, например: Update, Rollback, Diff, SQL Output, DBDoc, Generate changelog. Основа LiquiBase – файл изменений (changeLog). Это xml-документ следующего вида:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog/1.6"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog/1.6
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-1.6.xsd">
--- что-то важное ---
</databaseChangeLog>
В дальнейших примерах я не буду приводить корневой тег databaseChangeLog и все эти громоздкие подключения пространств имен, вместо этого я буду указывать только актуальное содержимое документа (то, что обозначено как “что-то важное”). Файл изменений содержит набор специальных команд “миграций”. Каждая миграция – сводится к одной из привычных для нас команд: создать или удалить таблицу, тоже для полей и индексов, например:
<changeSet id="1" author="Jim Tapkin">
<createTable tableName="cats">
<column name="id_cat" type="int">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(100)">
<constraints nullable="false"/>
</column>
<column name="sex" type="enum('m', 'f')" />
</createTable>
<addAutoIncrement tableName="cats" columnName="id_cat" columnDataType="int"/>
</changeSet>
Названия команд интуитивно понятны: createTable – создает таблицу, column – описывает создаваемые поля, а addAutoIncrement позволяет добавить в таблицу поле-счетчик. Гораздо интереснее посмотреть на тег changeSet: в нем задается имя автора изменений и номер изменений. Мда… пока не понятно, а где же те самые плюсы использования LiquiBase, вместо “ручного” написания sql-запросов? Давайте сначала запустим сценарий обновления:
liquibase.bat --classpath=путь-к\mysql-connector-java-5.1.3-rc-bin.jar --driver=com.mysql.jdbc.Driver
--url=jdbc:mysql://localhost/dbutest --username=user --password=pass --changeLogFile=log.xml migrate
Командная строка велика, но удобочитаема. Для того чтобы liquiBase мог подключится к базе данных, необходимо указать путь к jar-файлу с драйвером к БД (параметр --classpath). Затем указывается название драйвера (--driver) и url-строка адреса подключения(--url), а для аутентификации пользователя мы используем параметры --username и --password. Последний шаг – указать путь к xml-файлу со сценарием изменения БД (--changeLogFile) и команду, которую должен выполнить LiquiBase (migrate). Запустили, получили от LiquiBase сообщение “Migration successful” (миграция успешно завершена)? Теперь запустите команду еще раз. Запустили, получили то же сообщение “Migration successful” и никаких ошибок. Уже интересно. Теперь посмотрим, какие изменения произошли в самой БД. Я получил список таблиц и кроме ожидавшейся таблицы cats увидел, что в БД были добавлены еще две таблицы: databasechangelog и databasechangeloglock (это служебные таблицы LiquiBase). Первая из них играет роль журнала, какие обновления и когда были выполнены. Так что если вы запускаете одну и туже команду migrate несколько раз, то к ошибкам это не приведет. Кроме того, Liquibase обладает зачаточными способностями “отката”, когда выполненные изменения в БД (разумеется, не для всех команд) отменяются и БД возвращается в исходное состояние. Вторая же таблица (databasechangeloglock) служит для запрета одновременной попытки выполнить миграцию БД с нескольких различных машин в сети. Файл сценария обновления содержит не только команды “что нужно сделать”, но и так называемые условия. Условия (честно говоря, крайне примитивные и не сложные) проверяются перед применением файла сценария и могут запретить или разрешить обновления БД, например, так:
<preConditions>
<sqlCheck expectedResult="Ожидаемое Значение">Запрос</sqlCheck>
</preConditions>
Внутри тега sqlCheck задается текст sql-запроса, который должен вернуть одну строку, одну колонку, значение которой сравнивается с expectedResult. Если условие не выполнено, то и сценарий обновления не исполняется. Может быть полезным функция diff, когда LiquiBase сравнивает между собой две базы данных и формирует сценарий обновления одной из них до актуального состояния.
На этом я заканчиваю рассказ об LiquiBase. Рассказ получился не слишком большой, но, наверное, это и к лучшему: переписывать из официального руководства теги liquiBase я считаю бесполезным. А идея об использовании в разработке приложений работающих с БД таких продуктов как dbUnit и LiquiBase, надеюсь, будет вам полезна.