Предисловие
Считается, что unit-тесты не должны использовать реальных объектов (т.е. подключений к базам данных, сетевых сокетов и подобного рода ресурсов). На этой почве развилось очень много холиваров — надо тестировать код, работающий с базами данных, или это плохой тон. Если тестировать, то можно ли это называть Unit-тестирование или это функциональное тестирование (или интеграционное, т.к. мы тестируем совместную работу двух программных сред/модулей). Споры и баталии не утихают. Я же попрошу читателей не отвлекаться на священные войны, а принять этот материал как пищу для размышления. Давайте не будем забывать, что описанное мною лишь инструмент, его применимость определяется задачей.
Выбор инструментов
Пожалуй самое сложное в Unit-тестировании — это проверка кода, работающего с подключениями к базам данных (по большому счету, проверка кода, работающего с внешними объектами). Да, можно использовать mock'и взамен подключений, но если у вас более одной операции с JDBC-провайдером, то вы с большей вероятностью сделаете ошибку в mock-объекте, чем отловите ее в коде при помощи последнего. Что же остается? Использовать реальные базы данных тоже нехорошо, ведь сервер БД в репозиторий не положишь… А если я скажу, что очень даже положишь, и он уже там находится? Решение нашей проблемы — HSQLDB.
HSQLDB — это реляционная база данных полностью написанная на Java. При этом, что очень примечательно, сервер базы данных может подниматься как отдельным инстансом, так и создаваться внутри Java-приложения. Небольшой размер и возможность полностью хранить всю базу данных в памяти (по умолчанию) делают HSQLDB идеальным сервером БД для Unit-тестирования. С учетом того, что с точки зрения JDBC и ORM реализация СУБД не имеет значения (если вы придерживаетесь стандарта SQL и не злоупотребляете расширениями движков СУБД), то мы с легкостью сможем подменить подключение к PostgreSQL или Oracle на соединение с HSQLDB при Unit-тестировании.
Хорошо, предположим, что у нас есть база данных, полностью размещающаяся в памяти и потребляющая минимальное количество ресурсов. Перед проведением тестов ее нужно заполнить данными, и желательно это делать методом более универсальным, чем написание SQL-запросов. Нам так же нужно проверять состояние базы данных после проведения операций над ней. Получать из нее данные и сверять их с эталоном вручную, согласитесь, идея крайне плохая. Поэтому, для решения проблемы инициализации и проверки результатов операции была создана библиотека DBUnit, идеально подходящая для автоматизации инициализации базы данных и последующей сверки наборов данных.
Пример использования
Для демонстрации возможностей связки HSQLDB и DBUnit создадим класс, конструктор которого принимает в качестве параметров коннектор к базе данных. У класса будет метод, принимающий в качестве параметров строку текста, разбивающий ее на отдельные слова и складывающий статистику о частоте появления слов в таблицу базы данных. Наш класс будет выглядеть следующим образом:
public class User {
private Connection sqlConnection;
public User(Connection sqlConnectopn) {
this.sqlConnection = sqlConnectopn;
}
private int insertOriginalString(String originalString) throws SQLException {
int originalStringId = 0;
PreparedStatement psInsert = sqlConnection.
prepareStatement(
"INSERT INTO original_strings (strings, date) VALUES (?, now())",
PreparedStatement.RETURN_GENERATED_KEYS
);
psInsert.setString(1, originalString);
psInsert.execute();
ResultSet rsInsert = psInsert.getGeneratedKeys();
if(rsInsert.next()) {
originalStringId = rsInsert.getInt(1);
}
else {
throw new RuntimeException();
}
rsInsert.close();
psInsert.close();
return originalStringId;
}
private int insertToken(int originalStringId, String token) throws SQLException {
int tokenId = 0;
PreparedStatement psTokenId = sqlConnection.
prepareStatement("SELECT id FROM tokens WHERE word = ?");
psTokenId.setString(1, token);
ResultSet rsToken = psTokenId.executeQuery();
if(rsToken.next()) {
tokenId = rsToken.getInt(1);
}
else {
PreparedStatement psInsertToken = sqlConnection.
prepareStatement(
"INSERT INTO tokens (word) VALUES (?)",
PreparedStatement.RETURN_GENERATED_KEYS
);
psInsertToken.setString(1, token);
psInsertToken.execute();
ResultSet rsInserToken = psInsertToken.getGeneratedKeys();
if(rsInserToken.next()) {
tokenId = rsInserToken.getInt(1);
}
else {
throw new RuntimeException();
}
rsInserToken.close();
psInsertToken.close();
}
rsToken.close();
psTokenId.close();
return tokenId;
}
private void linkTokenToString(int originalStringId, int tokenId) throws SQLException {
PreparedStatement psCreateLink = sqlConnection.
prepareStatement("INSERT INTO links (original_string_id, token_id) VALUES(?,?)");
psCreateLink.setInt(1, originalStringId);
psCreateLink.setInt(2, tokenId);
psCreateLink.execute();
}
public void logRequestString(String requestString) throws SQLException {
String preParsed = requestString.replaceAll("\\W+", " ");
String[] tokens = preParsed.split(" ");
if(tokens.length > 0) {
int originalStringId = insertOriginalString(requestString);
for(String token: tokens) {
linkTokenToString(
originalStringId,
insertToken(originalStringId, token)
);
}
}
}
}
Теперь напишем unit-тест для него.
public class UserTest {
private IDatabaseTester tester = null;
@Before public void instantiate() throws Exception {
//Creating databse server instance
tester = new JdbcDatabaseTester("org.hsqldb.jdbcDriver", "jdbc:hsqldb:mem:" + UUID.randomUUID().toString(), "sa", "");
//Creating tables
tester.getConnection().getConnection().prepareStatement("CREATE SEQUENCE SEQU AS INTEGER START WITH 0").execute();
tester.getConnection().getConnection().prepareStatement("CREATE SEQUENCE SEQU2 AS INTEGER START WITH 0").execute();
tester.getConnection().getConnection().prepareStatement("CREATE SEQUENCE SEQU3 AS INTEGER START WITH 0").execute();
tester.getConnection().getConnection().prepareStatement("CREATE TABLE TOKENS(ID INT GENERATED BY DEFAULT AS SEQUENCE SEQU NOT NULL PRIMARY KEY, WORD LONGVARCHAR NOT NULL)").execute();
tester.getConnection().getConnection().prepareStatement("CREATE TABLE ORIGINAL_STRINGS(ID INT GENERATED BY DEFAULT AS SEQUENCE SEQU2 NOT NULL PRIMARY KEY, STRINGS LONGVARCHAR NOT NULL,DATE TIMESTAMP NOT NULL)").execute();
tester.getConnection().getConnection().prepareStatement("CREATE TABLE LINKS(ID INT GENERATED BY DEFAULT AS SEQUENCE SEQU3 NOT NULL PRIMARY KEY,TOKEN_ID INT NOT NULL,ORIGINAL_STRING_ID INT NOT NULL)").execute();
//Setting DATA_FACTORY, so DBUnit will know how to work with specific HSQLDB data types
tester.getConnection().getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new HsqldbDataTypeFactory());
//Getting dataset for database initialization
IDataSet dataSet = new FlatXmlDataSetBuilder().build(this.getClass().getClassLoader().getResourceAsStream("template_set.xml"));
//Initializing database
tester.setDataSet(dataSet);
tester.onSetup();
}
@Test
public void logRequestStringTest() throws SQLException, Exception {
User man = new User(tester.getConnection().getConnection());
man.logRequestString("Hello, world!");
ITable template = new FlatXmlDataSetBuilder().build(this.getClass().getClassLoader().
getResourceAsStream("check_set.xml")).getTable("tokens");
ITable actual = DefaultColumnFilter.includedColumnsTable(tester.getConnection().createDataSet().getTable("tokens"),
template.getTableMetaData().getColumns());
Assertion.assertEquals(template, actual);
}
}
Файлы наборов данных выглядят следующим образом:
template_set.xml
<dataset>
</dataset>
check_set.xml
<tokens WORD="Hello" />
<tokens WORD="world" />
При просмотре unit-теста может сразу возникнуть вопрос: «Почему код создания таблиц в базе включен в unit-test?! Обещали же наборы данных загружать из файлов?». Да, все верно, наборы загружаем из файлов, но структуру базы описать с помощью xml и заставить это работать со всеми драйверами баз данных — процесс не из легких, ввиду разнящегося синтаксиса DDL запросов для каждой СУБД. Поэтому такая функциональность отсутствует у DBUnit.
Хочу заострить ваше внимание на следующей конструкции:
ITable actual = DefaultColumnFilter.includedColumnsTable(tester.getConnection().createDataSet().getTable("tokens"),
template.getTableMetaData().getColumns());
Функция
DefaultColumnFilter
производит фильтрацию колонок, для сравнения набора данных не учитывая id записей.Заключение
В статье я разобрал самый простой пример работы с базой данных. Читатель легко домыслит, что такой подход к тестированию применим не только при «голом» использовании ODBC-соединений, но и для ORM-фреймворков. Стабильного вам кода!