Использование связки HSQLDB+DBUnit для Unit-тестирования Java-кода, работающего с базами данных

Предисловие


Считается, что 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-фреймворков. Стабильного вам кода!
Поделиться публикацией
Комментарии 13
    +1
    да это все прекрасно работает на пару с ОРМ, более того при некоторой фантазии можно сделать немного симпатичнее — я для себя скрещивал DbUnit с хибернейтом и yaml процессором чтобы получить возможность писать тесты наподобие этого: pastebin.com/q9xRV29g — DbUnitRule строка в начале это все, что мне нужно, чтобы данные из ямл файла о пользователе попали в БД и можно было проверить можно ли залогиниться этим самым пользователем. Сам ямл файл выглядит так: pastebin.com/3n8fJtBC — это позволяет дбюниту через хибернейтовский коннекшн впаять данные во все 4 таблицы

      +1
      Обычно, крупные проекты так или иначе завязаны на какие vendor-specific фичи БД. Так что маловероятно, что это заработает на практике
        0
        Согласен, под vendor-specific фичи такой подход не применим. Но под vendor-specific зачастую и пишут фасад на vendor-API и получают информацию подключаясь к фасаду, а не напрямую к СУБД. Здесь классические mock'и все решают.
        +1
        Примеры, наподобие Hello World, как правило совершенно не подходят для Production. Приведите реальные примеры, которые могут использоваться для крупных модульных проектов. Пока что ничего лучше интеграционного тестирования нам найти не удалось, хотя очень хочется.
          +1
          Сергей, а почему хочется? Интересно какой у вас юзкейс.

          Обычно, если команда уже отжалась на полноценный CI стенд с работающими автотестами, VCS хуками и нотификацией, привыкла к нему и тд, то назад возвращаться никому не охота. Появляется ведь возможность сделать автоматизированные перфоманс тесты, стресс тесты, HA и т.д., начинаются мысли о внедрении continuous delivery. Да и вобще удовольствие от работы как-то растет.
            0
            Мы сейчас работаем над сравнительно новым продуктом, и пока что приходиться тестироваться с помощью интеграционных тестов, которые проходят всю цепочку, начиная от клиентского фасада — серверный фасад — получение результата. Ради этого приходиться запускать веб-сервер, деплоить на него наши приложения, и только потом запускать тесты, используя клиентские фасады, которые обращаются к веб-серверу. Это не слишком долго, но хотелось бы, чтобы тест можно было запускать и видет результат его работы куда быстрее. Сейчас в серверном фасаде основные проблемы — работа с EJB моделями, а также с отдельными JDBC запросами. Ищу способ, как тестировать только серверный фасад, без необходимости каждый раз деплоить все на локальный сервер, но на рабочей (тестовой) базе данных.
        • НЛО прилетело и опубликовало эту надпись здесь
            0
            Спасибо, я посмотрю на H2 Database. Но мне кажется, что большой разницы не будет. Тестирование кода с помощью HSQLDB+DBUnit подходит лишь для небольших разработок — на них движок базы для тестов не будет играть большого значения
            –1
            HQSQL не поддерживает ни блокировок на уровне строк (что важно для обеспечения конкурентного доступа к данным), ни каких-либо фич присущих «взрослым» бд — так что ценность такого тестирования весьма сомнительна.
              +2
              Во-первых с конкурентным доступом у HSQL все ок — там MVCC, прям как у PostgreSQL. Во-вторых все равно протестировать на правильность конкурентного доступа довольно сложно и это явно выходит за рамки юнит-тестирования. Тут нагрузочное тестирование уже нужно использовать.
              +1
              Ругать не работать, конечно, но. Черт возьми, я не мог пройти мимо.
              1) HSQLDB — откуда взяли это старье? Выше правильно написали про H2, это уже давно рекомендуемый вариант для in-memory java dbms, в частности для тестов.
              2) Код и конфиги в таком виде, как в статье, нигде не используются. Подавляющее большинство юзает Spring или другой IoC, и датасорсы/конфиги подтягиваются через него. У нас ведь не только «тесты» и «не тесты». Как правило образуется набор окружений — dev, qa, staging, prod, etc. Это давным давно обкатано, используется и выглядит ясно и лаконично. См. static.springsource.org/spring/docs/3.0.0.M4/spring-framework-reference/html/ch12s08.html
              3) Создание схемы в яве — без комментариев. Заполнение словарных таблиц референсными данными наверное тоже там…

              Ладно, перестаю ругаться. Мой совет — Максим, отправьте это обратно в черновики и посмотрите как сейчас организуется тестинг с embedded базами в яве. Можно начать со ссылки выше.
                0
                По поводу кода, методов и фитч разобранных в статье — это лишь общий вариант. Безусловно сейчас не пишут SQL внутри JAVA-классов, но у меня и цель была не показать как писать database oriented приложения, я лишь показал возможную связку HSQLDB+DBUnit.
                За ссылку спасибо.
                  0
                  >HSQLDB — откуда взяли это старье?

                  Не так давно вышла версия 2.2 этой базы, судя по чейнджлогу там явно не старье. Хотя насчет сравнения с H2 ничего не могу сказать, возможно 2.2 релиз HSQL так и не дотянул до H2.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое