Пишем и тестируем код, работающий с БД, вместе с DBUnit и LiquiBase. Часть 1

July 27, 2008

Уверен, что никто не будет спорить с тем, что при разработке программного продукта одним из важнейших моментов является оценка его качества. Наличие даже самых небольших ошибок или несоответствий техническому заданию могут стать камнем преткновения и, если мы говорим о разработке коммерческого П.О., могут привести к убыткам, штрафам, закрытию проекта вообще. Частью процесса оценки качества является тестирование. Я уже поднимал этот вопрос в своей серии статей посвященных тестированию веб-сайтов с помощью badboy и jmeter. Тогда же я рассказал об основных видах тестирования: юнит-тестировании (т.е. тестировании отдельных модулей, классов, функций – небольших строительных блоков из которых, по сути, и состоит вся программа). Существует также интеграционное тестирование (когда мы собираем те самые кусочки воедино). Есть и системное тестирование (именно этому виду и были посвящены статьи про badboy и jmeter).

Сегодняшняя статья рассказывает об unit-тестировании и такой его части как тестирование кода работающего с базой данных. Собственно говоря, больших проблем или страшных секретов здесь нет. Любое приложение можно условно разделить на несколько частей, модулей. Каждый из них нужно тестировать сначала отдельно (юнит-тестирование), а затем вместе (интеграционное тестирование). Естественно, что при тестировании модулей возникает проблема “а этой части еще нет”. Т.е. модули приложения не существуют отдельно, а зависят, пользуются услугами друг друга. В этой ситуации используются mock-и, или имитации. Некоторую часть системы (возможно еще и не существующую) нужно заменить на ее суррогат, имитацию. Такой подход дает возможность не только разрабатывать часть модулей приложения параллельно, но и уменьшает количество ложных ошибок. Действительно, шанс того, что ошибка будет допущена в имитации (максимально упрощенной и не содержащей настоящей бизнес-логики) крайне мала. В состав почти всех известных и популярных библиотек и фреймворков входят подобные имитации. Например, чтобы проверить ваш код, работающий в рамках некоторой среды “X”, вовсе не обязательно запускать веб-сервер, развертывать на нем веб-приложение, затем имитировать запрос пользователя из браузера. Все эти операции, конечно, не составляют большой сложности, но требуют значительных временных затрат. Поэтому существует понятие “легких тестов”. Т.е. когда мы говорим, что программа, предназначенная работать совместно с некоторой огромной и громоздкой инфраструктурой (сервером приложений, базой данных), может быть протестирована на имитации этой инфраструктуры. Условно говоря, можно создать объект “имитация_веб_сервера”, поместить в него код вашего приложения, затем создать объект “имитация_запроса_клиента”, а затем оценить какие ошибки возникли и почему. Больших секретов я не раскрыл, да и не собираюсь этого делать, т.к. рассказ об таких методиках тестирования неизбежно связан с “узкими” технологиями, которые вряд ли будут интересны массовому читателю. Сегодняшняя тема рассказа – тестирование кода работающего с базами данных и средства позволяющие упростить процесс эволюции структуры БД по ходу развития проекта. Те инструменты, о которых я расскажу, имеют общий характер и будут полезны любому из java-программистов без учета того, с каким конкретно framework-ом он работает (spring, jsf, webwork …). Также я предполагаю, что читатель знаком с таким универсальным инструментом тестирования в java как junit. Благо есть достаточное количество информации (и даже на русском языке) по этой теме.

Начну я с того, что расскажу об основных проблемах, которые стоят при разработке приложений интенсивно работающих с СУБД и их тестировании. Не секрет, что как можно раньше необходимо выполнять тесты на данных максимально похожих на “настоящие”. Большинство проблем возникают из-за того, что разработка программ и их тестирование ведется на “не тех данных”. Это когда приложение, предназначенное для учета, например, товаров на крупном складе оперирует тестовой базой данных размером в несколько десятков записей. А затем (в ходе эксплуатации созданной программы) начинаются проблемы: почему так медленно строится отчет, почему графики, например, показателей продаж превращаются в нечто визуально неудобочитаемое. Другой проблемой является то, что данные, внесенные в тестовую СУБД, не слишком корреллируются с реальной жизнью: цены “от балды” на товар помогут пропустить ошибку переполнения, например, при расчете цены. А созданные для теста БД записи накладных на две-три позиции товаров, наверняка, принесут ряд “приятных” сюрпризов при печати на бланке строгой отчетности накладной, в состав которой входит несколько десятков позиций. И подобных примеров можно придумать еще очень много. Так что все согласятся с тем, что тестовые данные должны быть максимально полными и близкими к реальной жизни. Теперь предположим, что вы написали модуль перевода денег с одного счета на другой, а для теста были подготовлены в СУБД некоторые таблицы. Нам осталось только запустить тесты, а после их завершения проверить, чтобы состояние СУБД (результат работы вашего кода) совпал с эталонным, ожидаемым. Очевидно, что после запуска тестов состояние СУБД будет искажено по сравнению с оригинальным. И вы должны будете его при повторном запуске теста (а таких пусков за день может быть добрый десяток) привести СУБД в изначальное состояние. В старые “темные” времена это решалось созданием специального файла сценария, содержавшего sql-команды на удаление всей устаревшей информации и заполнении базы заново. Естественно, что на программисте/тестировщике лежала ответственность “не забыть” перед запуском теста запустить нужный сценарий подготовки. Ситуация усложнялась тем, что часто для каждого из тестов (а их в пакете могли быть сотни и тысячи) нужна была своя, особая среда окружения. Например, для теста 13 нужно чтобы в таблице была запись о товаре “X”, а для теста 113, запись должна, наоборот, отсутствовать. Таким образом, возникла необходимость в создании средства выполняющего рутинные действия по “созданию” тестовой среды и, обязательно, легко интегрирующимся с такими общими инструментами тестирования как junut или testng. Именно решению этой проблемы будет посвящен dbUnit.

Вторая же часть статьи рассказывает об LiquiBase. Что это такое и зачем нужно? Согласитесь, что ситуация, когда проект (да хоть тот же складской учет) сдан и больше в него изменений не вносится, крайне невероятна. После того как заказчик получил программу и начал пользоваться ею, неизбежно будет приходить понимание того, что “здесь мы забыли”, “а появилась новая потребность”, “изменились законы” … Не секрет, что львиная доля стоимости ПО определяется затратами на его поддержку. А заказ на разработку новой программы чаще всего приходит тогда, когда затраты на поддержание и внесение изменений в старую версию становятся дороже, чем затраты на “выкинуть все старье на помойку и переписать программу наново”. Мы принимаем заказ на доделку программы, вносим в нее изменения и (вот она суть проблемы) мы вынуждены менять структуру БД. Правки могут быть простыми, как например, добавить новое поле в таблицу товаров, так и более сложными, когда нужно информацию из одной “старой” таблицы разнести по нескольким “новым”, обновить значения определенных полей в таблицах ... Затем наступает ответственный момент, когда администратор СУБД начинает сравнивать структуру базы данных, “которая у клиента” и та, которая “у нас”; определяет какие правки нужно выполнить для миграции с одной версии БД на другую. Наконец, ровно в полночь, в пятницу (или любой другой момент, когда заказчик не работает) база данных “разбирается” и “собирается” заново. Данный процесс не является сложным, но требует крайней внимательности, ведь допущенная ошибка приведет к тому что нужно будет восстановить БД из резервной копии, на это уходят драгоценные часы, а когда часы пробьют ровно двенадцать, карета превратится в … Одним словом, вы не успели: время на миграцию БД исчерпано, задача не выполнена, начинается новый рабочий день, простои в котором не допустимы. Особый вкус задача внесения правок в БД приобретает, когда ведется параллельная разработка нескольких “очень похожих” версий для разных заказчиков. Здесь уже не обойтись старым добрым текстовым файлом с указанием на какую дату, и для какой обновленной функции программы, какие правки нужно сделать в БД, чтобы все заработало. Для решения подобной проблемы предназначен второй инструмент сегодняшнего обзора – LiquiBase. Однако довольно слов, давайте перейдем к делу и начнем с dbUnit (хотя в примерах везде используется mysql, но никакой разницы между ним и любой другой СУБД с точки зрения dbUnit нет).

Предположим, что вы успешно загрузили библиотеку dbUnit с сайта http://www.dbunit.org (для корректной работы dbunit могут потребоваться дополнительные библиотеки, которые можно найти на сайте http://www.slf4j.org/). Теперь нужно спроектировать БД и написать код использующий ее. В БД будут две таблицы-справочника: одна с перечислением покупателей, вторая с перечнем товаров, а третья таблица будет играть роль промежуточной, т.е. хранящей сведения о покупках (см. рис. 1).


  1. CREATE TABLE `users` ( 
  2.    `id_user` int NOT NULL AUTO_INCREMENT,  
  3.    `fio` varchar(100),  
  4.    `birthday` date,  
  5.    `sex` enum('m','f'),
  6.    PRIMARY KEY (`id_user`)
  7. ) ENGINE=InnoDB
  8.  
  9. CREATE TABLE `articles` ( 
  10.    `id_article` int NOT NULL AUTO_INCREMENT,  
  11.    `title` varchar(100),  
  12.    `produced` date, 
  13.    `price` float, 
  14.    PRIMARY KEY (`id_article`)
  15. ) ENGINE=InnoDB
  16.  
  17. CREATE TABLE `purchases` (  
  18.    `id_purchase` int NOT NULL AUTO_INCREMENT,
  19.    `id_user` int(11) NOT NULL, 
  20.    `id_article` int(11) NOT NULL,  
  21.    `price` float DEFAULT NULL,  
  22.    `qty` int(11) DEFAULT NULL,  
  23.    PRIMARY KEY (`id_purchase`), 
  24.    FOREIGN KEY (`id_user`) REFERENCES `users` (`id`) ON DELETE CASCADE,
  25.    FOREIGN KEY (`id_good`) REFERENCES `articles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
  26. ) ENGINE=InnoDB
Перед написанием тестов необходимо создать файл-сценарий для dbUnit, который будет содержать набор тестовых данных. Есть несколько альтернатив формата для этого файла: можно использовать xml, csv, excel. Начнем с xml, вот его пример:
  1. <?xml version='1.0' encoding='UTF-8'?>
  2. <dataset>
  3.    <users id="1" fio="Jim" birthday="1999-01-01" sex="m"/>
  4.    <users id="2" fio="Tom" birthday="1989-01-01" sex="m"/>
  5.    <users id="3" fio="Marta" birthday="1959-01-01" sex="f"/>
  6. </dataset>
Как видите, ничего сложного: внутри корневого элемента dataset находятся записи соответствующие каждой из таблиц, которую нужно заполнить. Имя таблицы задано как имя тега, а атрибуты этого тега соответствуют полям таблицы (в случае если значение какого-либо из атрибутов отсутствует – значит, в таблицу будет вставлено значение по-умолчанию). В случае, если у вас уже есть БД, наполненная тестовой информацией, то можно воспользоваться следующим примером кода, который выгрузит эту базу в XML-файл:
  1. // загружаем драйвер для работы с mysql
  2. Class.forName("com.mysql.jdbc.Driver");
  3.  
  4. // получаем подключение к серверу СУБД
  5. Connection jdbcConnection = DriverManager.getConnection( "jdbc:mysql://localhost/dbutest?useUnicode=true&characterSet=UTF-8", "root", "");
  6. IDatabaseConnection iConnection = new DatabaseConnection(jdbcConnection);
  7.  
  8. // экспортируем часть базы данных
  9. QueryDataSet partialDataSet = new QueryDataSet(iConnection);
  10.  
  11. // экспорт таблицы, но не всей, а только определенных записей
  12. partialDataSet.addTable("users", "SELECT * FROM users where sex = 'm' ");
  13.  
  14. // экспорт всей таблицы
  15. partialDataSet.addTable("articles");
  16.  
  17. // сохраняем изменения в файл
  18. FlatXmlDataSet.write(partialDataSet, new FileOutputStream("users-and-articles-dataset.xml"));
  19.  
  20. // экспорт всей базы данных полностью
  21. IDataSet fullDataSet = iConnection.createDataSet();
  22. FlatXmlDataSet.write(fullDataSet, new FileOutputStream("all-tables-dataset.xml"));
В ходе работы данного кода на экране будут выводиться сообщения об ошибках, но на них не стоит обращать внимания: файлы xml с данными для экспорта все равно будут созданы. Условно код состоит из следующих шагов:
  1. создать подключение к БД с помощью старого доброго JDBC и “обернуть” это подключение внутрь объекта IDatabaseConnection.
  2. создать объект DataSet и наполнить его правилами “что подлежит экспорту”.
  3. выполнить запись DatSet в файл с помощью объекта FlatXmlWriter (в этом примере создание FlatXMLWriter выполняется прозрачно, но в последующих примерах нам потребуется явно записать код создания Writer-a).

Возвращаясь к примеру, в первом случае я выполняю экспорт только двух таблиц из БД. Более того, для таблицы users я задал sql-код запроса, так что в файл со “снимком” будут помещены только те записи, для которых выполнено условие “sex=’m’”. Вторая таблица – articles – была помещена в файл без ограничений. Кроме того, я создал снимок всей БД и поместил его в файл “all-tables-dataset.xml”. Именно рассматривая данный файл можно столкнуться с проблемой:
  1. <dataset>
  2.     <articles id_article="1" title="milk" produced="2008-01-01" price="100.0"/>
  3.     <purchases id_purchase="1" id_user="1" id_article="1" price="2000.0" qty="3"/>
  4.     <users id_user="1" fio="Jim" birthday="1999-01-01" sex="m"/>
  5. </dataset>
Видите в каком порядке перечислены операции по вставке данных в БД? Сначала товары, затем покупки, и напоследок сведения об покупателях. Довольно забавно: как можно создать запись о покупке ссылающейся на некоторого пользователя # 1, если этот пользователь будет добавлен только спустя какое-то время. Нам нужен механизм упорядочения порядка вставки записей. В dbUnit за это отвечает интерфейс ITableFilter и несколько классов реализующих его. Например, класс SequenceTableFilter. При его создании в качестве параметра конструктору нужно передать список имен таблиц; и именно в таком порядке таблицы будут экспортированы в xml-файл. В случае, если у вы хотите отобрать не все записи из таблиц и при этом не можете обойтись простым sql-запросом, то можно попробовать использовать PrimaryKeyFilter. В качестве параметра конструктора для этого класса передается “карта” (Map) в качестве ключей которой используется имена таблиц, а значениями являются списки допустимых значений первичного ключа. Класс ExcludeTableFilter служит для того, чтобы исключить из списка экспортируемых из БД таблиц те, имена которых переданы как параметр конструктору класса. И, наконец, самый полезный класс DatabaseSequenceFilter: именно он и отвечает за процесс автоматического упорядочения таблиц при экспорте, основываясь на связях между таблицами. Вот пример использования этого класса:
  1. ITableFilter filter = new DatabaseSequenceFilter(iConnection);
  2. IDataSet fullDataSet = new FilteredDataSet(filter, iConnection.createDataSet());
  3. FlatXmlDataSet.write(fullDataSet, new FileOutputStream("all-tables-dataset.xml"));
Результирующий xml-файл уже выглядит приемлемым образом: сначала идет вставка данных из таблицы articles, затем users и последней идет purchases. К сожалению всех проблем связанных с “правильным порядком вставки записей” так не решить: осталась открытая проблема с вставкой рекурсивных данных. Неприятности появятся и при очистке содержимого таблиц с отключенными каскадными обновлениями/удалениями. Поэтому я предпочитаю самостоятельно в процедуре подключения к СУБД отключить на время связи между таблицами. Для mysql, например, это делается так:
  1. -- так мы отключаем
  2. SET FOREIGN_KEY_CHECKS =0
  3. -- а так включаем, после того как данные были внесены в БД
  4. SET FOREIGN_KEY_CHECKS =1
При экспорте данных может пригодиться и следующий фрагмент кода. Его назначение – выгрузить в xml-файл содержимое таблицы “purchases” и всех таблиц, от которых она зависит (dependency).
  1. String[] deps = TablesDependencyHelper.getAllDependentTables(iConnection, "purchases");
  2. IDataSet depsDS = iConnection.createDataSet(deps);
  3. FlatXmlDataSet.write(depsDS, new FileOutputStream("deps.xml"));
Теперь разберемся с тем, как dbUnit на стадии импорта данных в БД определяет то, как должна выглядеть структура целевых таблиц. В общем случае никаких секретов здесь нет: на основании файла с примерами импортируемых данных (точнее на примере первой записи в этом файле), dbUnit принимает решение, какие поля должны быть в таблице. Это не совсем правильно, например, в случае, если первая запись экспортированных данных не содержит значения всех полей (атрибутов). В этом случае имеет смысл сгенерировать DTD-файл, он не только решает описанную выше проблему, но и позволяет проверить xml-файл с данными на предмет корректности на стадии импорта данных в БД.
  1. // создаем объект DataSet с правилами "что нужно экспортировать"
  2. IDataSet allDataSet = iConnection.createDataSet();
  3.  
  4. // записываем эти сведения внутрь dtd-файал
  5. FlatDtdDataSet.write(allDataSet, new FileOutputStream("db.dtd"));
  6.  
  7. // теперь создаем объект xml-writer-а
  8. FlatXmlWriter DTDwriter = new FlatXmlWriter(new FileOutputStream("with-dtd-dataset.xml"));
  9.  
  10. // нам нужно указать в качестве параметров имя dtd-файла
  11. DTDwriter.setDocType("db.dtd");
  12.  
  13. // экспортируем содержимое БД в xml-файл
  14. DTDwriter.write(allDataSet);
Последний полезный прием при экспорте содержимого БД во внешний xml-файл – настройка streaming. Streaming – механизм позволяющий эффективно работать с большими по объему наборами данных при экспорте:
  1. DatabaseConfig config = iConnection.getConfig();
  2. config.setProperty(DatabaseConfig.PROPERTY_RESULTSET_TABLE_FACTORY, new ForwardOnlyResultSetTableFactory());
В следующий раз я завершу рассказ об dbUnit – нам осталось разобраться с тем, как импортировать данные из xml-файла, как интегрировать dbUnit с jUnit. Затем я расскажу о том, как помогает “развивать” структуру БД LiquiBase.

Categories: Java