Предисловие
Считается, что 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-фреймворков. Стабильного вам кода!
