Переход на embedded PostgreSQL в unit-тестах +41



В приложениях, работающих с базами данных, естественным образом возникает потребность в тестах, которые проверяют корректность результатов выполнения запросов. На помощь приходят различные встроенные (embedded) базы данных. В этой статье я расскажу о том, как мы перевели unit-тесты с HSQLDB на PostgreSQL: зачем это затеяли, с какими трудностями столкнулись и что нам это дало.


Если в тестах нужно проверить работоспособность sql-запросов, удобно использовать так называемые встроенные базы данных. База создается при запуске теста и удаляется при завершении — таким образом, ее жизненный цикл ограничен процессом, в котором она была запущена (оттого они и называются embedded — то есть встроенные в процесс тестирования). Преимущество таких баз данных в том, что они позволяют работать с ограниченным набором тестовых данных, который, как правило, значительно меньше объема данных даже на тестовом сервере. Кроме того, создание базы, наполнение таблиц и последующее удаление всей инфраструктуры совершенно прозрачно для программиста: нет необходимости поднимать и настраивать тестовый сервер, а также заботиться об актуальности данных, что также положительно сказывается на скорости разработки.

В нашей компании для unit-тестирования исторически использовалась HSQLDB. И все бы ничего, но на продакшене стоит PostgreSQL, и получалось, что тесты не полностью отражают то, что происходит на проде. Некоторые фичи приходилось оставлять без тестирования: например, запросы, использующие window functions, протестировать не удавалось. Для некоторых тестов приходилось делать достаточно навороченные костыли: яркий тому пример — разная реализация мэппинга пользовательского типа массива на SQL в PostgreSQL и HSQLDB (ниже я расскажу об этом подробнее). Также мы столкнулись с проблемной реализацией exists — были случаи, когда ни с того ни с сего тест падал с NPE где-то в недрах HSQLDB.

Всех этих проблем можно было избежать, если бы в качестве СУБД для тестов мы использовали PostgreSQL. На данный момент существует две реализации embedded PostgreSQL: библиотека postgresql-embedded из yandex-qatools и otj-pg-embedded, представленная компанией OpenTable. Оба проекта достаточно активно развиваются, о чем свидетельствуют регулярные коммиты в репозитории, а также удобны в использовании: библиотеки можно загрузить из maven-репозитория, в обеих поддерживается работа под Windows, что немаловажно в компании, где часть разработчиков выбрали для работы именно эту ОС. В итоге мы решили остановиться на otj-pg-embedded по нескольким причинам:


  • Во-первых, библиотека OpenTable оказалась значительно быстрее: время запуска при скачанном дистрибутиве составляет около 2 секунд, при этом yandex-qatools запускается в течение 7,5 секунды — практически в 4 раза медленнее! Выяснилось, что основное время идет на разархивацию дистрибутива при каждом запуске, при этом на машинах с Windows время старта возрастало до нескольких минут.
  • Во-вторых, достаточно просто подменить версию PostgreSQL, если понадобится использовать более старую версию или кастомную сборку с расширениями. По умолчанию otj-pg-embedded 0.7.1 идет с PostgreSQL 9.5 (в версии 0.8.0, актуальной на сегодняшний день, — PostgreSQL 9.6). При этом не совсем ясно, каким образом можно подложить кастомную сборку в yandex-qatools.
  • otj-pg-embedded — достаточно старый проект (первый коммит был в феврале 2012 года), на запросы в github мейнтейнеры отвечают оперативно.
  • В yandex-qatools больше возможностей для конфигурации, в то же время otj-pg-embedded — небольшая библиотека, в которой легко разобраться, при этом она предоставляет достаточно возможностей для конфигурирования.
  • В библиотеке OpenTable запускать экземпляр БД можно “одной строкой”, что очень удобно и интуитивно понятно. Кроме того, в версии 0.8.0 при запуске выводится достаточно информативный лог с указанием настроек, с которыми запущен PostgreSQL.

Итак, определившись с библиотекой и решительным жестом заменив все зависимости от hsql на otj-pg-embedded, мы радостно запустили тесты и…


Первые грабли


По-разному обрабатывается sql-тип DATE


Первым делом выяснилось, что упали тесты, которые работали с sql-типом Date (не Timestamp, а именно Date). Например, при поиске резюме по определенному критерию запрос возвращает все резюме, у которых дата изменения позже указанной даты:


return sessionFactory.getCurrentSession().createQuery(
   "FROM ResumeViewHistory " +
       "WHERE resumeId=:resumeId  AND date IN " +
       "  (SELECT max(date)  FROM ResumeViewHistory " +
       "   WHERE resumeId=:resumeId AND date>:date)")
   .setInteger("resumeId", resumeId)
   .setDate("date", from)
   .list();

В тесте создавалось резюме с текущей датой и затем проверялось, что не существует резюме с датой немного старше, чем в только что созданном:


assertTrue(resumeViewHistoryDao.findByResumeLastView(
    resume.getResumeId(), new DateTime().plusMinutes(1).toDate())
.isEmpty());

Этот тест, как и все остальные тесты с аналогичной проверкой, в PostgreSQL упал с AssertionError. Все дело в том, что в HSQLDB производилось сравнение даты и времени, поэтому разница в одну минуту считалась существенной. А в PostgreSQL время обнулялось, превращаясь просто в дату, и запрос вполне себе находил резюме, ведь оно было создано в этот же день!

Таким образом, мы практически на первых порах столкнулись с ситуацией, когда тест проверял sql-запрос, предполагая определенное поведение при заданных условиях, и это поведение отрабатывало на HSQLDB и не работало на PostgreSQL. То есть фактически этот тест был не только бесполезен, поскольку не давал никакой информации о работе запроса на продуктивной базе, но и даже вреден: разработчик, читая код теста и увидев, что есть проверка на пустой результат для объекта, созданного минуту спустя, мог бы подумать, что на уровне DAO используется метод setTimestamp(), и весьма бы удивился, увидев там setDate().


LIKE + CTE


Следующую проблему подкинул оператор LIKE в связке с общими табличными выражениями. CTE (Common Table Expressions) — это выражения, определяющие временные таблицы для использования в более сложном запросе. Эти временные таблицы создаются только для текущего запроса, что удобно для краткосрочного представления данных в том или ином виде. Например, Employer (работодатель, он же — клиент) — одна из центральных сущностей — с помощью CTE может быть представлена как более специализированный объект с сокращенным набором полей.

Допустим, нам нужно найти дубликаты клиентов — то есть компании, у которых есть совпадения по нескольким параметрам, например, адрес регистрации или url сайта. Среди прочего есть проверка на совпадение имени компании (или ее части), тут как раз в дело вступает оператор LIKE:


WITH clients(name) AS (VALUES …)
 SELECT 
 employer.employer_id AS employerId, 
 employer.name AS employerName 
   FROM employer AS employer 
   JOIN clients AS clients ON employer.name LIKE '%' || clients.name || '%'

Итак, для следующего списка клиентов:


  • Alice
  • Bob
  • Хэдхантер
  • Клиент
  • Alice Cooper
  • Bob Marley

результат запроса сильно отличался в зависимости от того, что подставлялось в оператор WITH (см. таблицу).




Похоже, что PostgreSQL всегда дает предсказуемый результат, а у HSQLDB проблемы во всех случаях, когда во временную таблицу попадает более одного значения, которое должно смаппиться join’ом. В результате пришлось поправить исходные данные в тесте, поскольку, как видно из таблицы, PostgreSQL и HSQLDB по-разному реагируют на множественные значения во временной таблице, а в тесте проверялось как раз несколько клиентов.


Разная реакция на некорректный SQL


В нашей модели данных есть объекты, которые хранят sql-код, который выполняется для того, чтобы выяснить, подходит ли объект под определенные условия. Естественно, появляется желание проверять поведение системы, когда она сталкивается с некорректным sql-кодом, поэтому у нас есть соответствующий тест: в объект передается некорректный sql, и затем вызывается метод, который этот sql выполняет.


SELECT user_id FROM user111 WHERE employer_id = :employerId

Конечно, таблицы user111 не существует — это ошибочное написание таблицы user. При попытке выполнения некорректного sql обрабатывается RuntimeException, исключая объект из дальнейшего процесса; именно это поведение и проверялось тестом. Действительно, при выполнении на HSQLDB код падал с ошибкой SQLException (наследуется от Exception), которая затем конвертировалась в JDBCException (наследуется от RuntimeException), это исключение в свою очередь обрабатывалось в методе сервиса, убирая проверяемый объект из результатов, затем выполнялись дополнительные операции для успешно обработанных объектов — и тест корректно завершался.

При запуске теста на PostgreSQL ошибка по-прежнему перехватывалась, преобразовывалась в JDBCException и отправлялась дальше, некорректный объект исключался из обработки, и все бы хорошо, но следующие действия прерывались из-за того, что при возникновении ошибки подобного рода в запросе — а именно, при попытке доступа к несуществующей таблице — PostgreSQL прерывает транзакцию, и остальные запросы уже не обрабатываются, что в итоге приводило к некорректному завершению теста.


Проблемы с сортировкой


Здесь можно выделить два класса проблем. Первый — это несоответствие порядка сущностей в коде тестов и в результате запроса. Например, в тесте есть список из нескольких экземпляров, которые затем создаются в базе и выгружаются из нее также в виде списка. Так вот, в HSQLDB порядок, в котором объекты загружались из базы, всегда совпадал с порядком в исходном списке. В PostgreSQL порядок не совпадал чуть менее, чем во всех тестах, где использовалась подобная конструкция, что привело к необходимости либо использовать явную сортировку исходного и результирующего списков, либо сравнивать списки без учета порядка элементов (кстати, для этих целей достаточно удобно использовать метод arrayContainingInAnyOrder из матчер-фреймворка Hamcrest).

Вторая проблема оказалась серьезнее: перестали работать некоторые тесты, в которых использовалась сортировка на уровне БД (ORDER BY). Причем сортировка вела себя по-разному при запуске на разных окружениях: после замены названий у пары вакансий тест стал корректно отрабатывать на Linux, но по-прежнему не работал при запуске на MacOS и Windows.

Естественно, подозрение пало на настройки локали в PostgreSQL — а именно на ту из них, которая отвечает за порядок сортировки строк в базе, — LC_COLLATE. Эта настройка не зависит от кодировки, указанной при создании базы: если не указать этот параметр в явном виде при инициализации базы в createdb или initdb, PostgreSQL возьмет значение из операционной системы (кстати, поменять его после инициализации базы нельзя). Правила сортировки могут быть самыми разными: например, учитывать или не учитывать пробелы, а также определение приоритета строчных или прописных букв.

Таким образом, мы получили тесты, результаты которых зависят от операционной системы, — то есть достаточно паршивую ситуацию. Можно было бы использовать настройку, которая позволяет выполнять сортировку по правилам, не зависящим от ОС, воспользовавшись поставляемой с PostgreSQL стандартной сортировкой, LC_COLLATE=C, но этот вариант не подошел, поскольку в реализации embedded-pg от OpenTable нет возможности передавать параметры в initdb. В текущей реализации можно изменять конфигурацию сервера при старте базы, но задавать настройки collate там нельзя, поэтому проблемные тесты временно отправились в @Ignore. Кстати, мейнтейнер OpenTable признал необходимость данной фичи, поэтому после краткого обсуждения мы создали PR, в котором добавили возможность инициализировать базу с разными настройками локали. Релизы происходят нечасто, но, надеемся, что уже в следующей версии embedded-pg эта проблема будет решена.


Проблема мэппинга пользовательских типов


Как известно, в Hibernate для хранения в базе поля типа “массив” необходимо написать пользовательский тип, реализующий интерфейс UserType. Достаточно лишь реализовать необходимые методы, в том числе sqlTypes(), определяющий SQL-тип столбца в базе, а также nullSafeSet() и nullSafeGet(), и добавить аннотацию @Type с указанием пользовательского типа к объявлению поля в классе сущности (или указав этот тип в файле мэппинга).

В наших проектах для чтения и записи объектов типа массива изначально использовался java.sql.Array, который отлично работает в связке с PostgreSQL на продакшене. Но, поскольку в HSQLDB мэппинг таким образом не работал, а тестировать сущности, которые в своем составе имеют массивы, было необходимо, мы немного поменяли реализацию пользовательских типов. Непосредственной имплементацией nullSafeSet() и nullSafeGet() теперь управляли стратегии: по умолчанию использовался код PostgreSQLArrayStrategy, работающий с java.sql.Array, а во время выполнения тестов в пользовательские типы передавалась специальная стратегия HsqldbArrayStrategy, которая для чтения и записи массивов использовала java.sql.Blob.

После перевода тестов на PostgreSQL мы наивно надеялись, что стоит просто убрать все связанное с HSQLDB и мэппинг заработает сам собой, но, конечно же, все было не так просто. На продакшене для получения соответствующего java-типу sql-типа используется класс AbstractJdbcConnection, который корректно резолвит типы массивов без всяких дополнительных настроек. К сожалению, в случае с тестами такой подход работал лишь наполовину: connection использовался только при непосредственном выполнении тестов. В то же время при создании тестовой базы работает другой механизм определения типов, а именно — их мэппинг происходит во время создания SessionFactory, когда инициализируются метаданные, представляющие собой ORM-модель. Этому механизму по-прежнему требовалось явно указывать тип массивов в описании диалекта, иначе Hibernate просто не удавалось создать таблицу с полем типа “массив”:

org.hibernate.MappingException: No Dialect mapping for JDBC type: 2003

Проблема заключалась в том, что мы не могли указать sql-тип для массивов в файле диалекта, потому что у нас их было два (String и Integer), а связать таким образом можно только один тип, например:


registerColumnType(Types.ARRAY, "integer[$l]");

Пришлось искать обходной путь: для каждого пользовательского типа был создан свой идентификатор типа массива, например, строковые массивы были связаны с типом 200301, а массивы целых чисел — с типом 200302. Этот идентификатор возвращался в методе sqlTypes():


public class EnumArrayUserTypeString extends EnumArrayUserType {
  public static final int VARCHAR_ARRAY_SQL_TYPE = 200301;
  @Override
  public int[] sqlTypes() {
    return new int[] {VARCHAR_ARRAY_SQL_TYPE};
  }
  //...
}

Затем типы были зарегистрированы в диалекте:


public class HHPostgreSQLDialect extends PostgreSQL9Dialect {
  public HHPostgreSQLDialect() {
    registerColumnType(EnumArrayUserTypeString.VARCHAR_ARRAY_SQL_TYPE, "varchar[$l]");
    registerColumnType(EnumArrayUserTypeInteger.INT_ARRAY_SQL_TYPE, "integer[$l]");
  }
// ...
}

После этих манипуляций Hibernate наконец-то остался доволен и без проблем создал нужные таблицы.


Проблемы с производительностью


На закуску — самый животрепещущий вопрос: а что же стало с производительностью тестов? Ведь HSQLDB размещалась непосредственно в памяти, а embedded-pg — хоть и встроенная, но фактически полноценная база данных, при старте создающая целую кучу файлов!

Первые результаты были довольно удручающие: на машинах с SSD при прогоне всех тестов в модуле скорость упала на 25–30%, на более старых машинах с SATA все было еще хуже (замедление до 40%). Ситуация становилась заметно лучше, если тесты запускались в несколько потоков:

mvn clean test -T C1

— но все равно не вызывала восторга, особенно у тех, кто и раньше прогонял тесты в многопоточном режиме.

Мы обратили внимание на подозрительные логи при тестировании проекта, состоящего из нескольких модулей: как только surefire приступал к тестированию очередного модуля, запускался новый инстанс базы данных, со всеми вытекающими: создание схемы данных, заполнение таблиц тестовыми данными, — в общем, множество дорогостоящих I/O-операций. Это показалось странным, потому что за создание инстанса базы отвечал холдер синглтона, который был написан по всем правилам, включая volatile-поле для хранения инстанса и double-check locking в методе получения. Каково же было наше удивление, когда выяснилось, что этот синглтон оказался не совсем синглтоном, когда в дело вступал maven-surefire-plugin!

По умолчанию при тестировании очередного модуля surefire создает новый процесс JVM. В этом процессе выполняются все тесты, относящиеся к модулю; когда тестирование завершено, surefire переходит к следующему модулю, который будет выполняться в новой JVM. Настройкой этого поведения управляет параметр forkCount, который по умолчанию равен 1 (одна новая JVM на каждый модуль). Если этот параметр выставить в 0, то все модули будут выполняться в одной и той же JVM.

Стало ясно, что при использовании forkCount=1 мы получали новый инстанс PostgreSQL при старте каждого модуля. Тогда было решено попробовать использовать одну JVM для всех тестов, но и в этом случае оказалось, что инстанс создается каждый раз при старте очередного модуля! Причиной тому служил так называемый Isolated Classloader, который использовался surefire в случае forkCount=0. Несмотря на явное указание использовать системный класслоадер (useSystemClassloader=true), в данном случае surefire переключается на изолированный (кстати, сообщая об этом в логах, да и в документации на этом поведении сделан акцент — см. Class Loading and Forking in Maven Surefire). Итак, для каждого модуля создается отдельный класслоадер, который загружает все классы заново и, соответственно, вновь создает инстанс нашего синглтона со ссылкой на инстанс базы. Таким образом, проблема была обусловлена внутренним механизмом работы surefire, и мы решили сосредоточиться на тех аспектах производительности, которые можно было поправить.


Временные файлы в RAM


Сначала мы решили перенести временные файлы, которые embedded-pg создает во время своей работы, с дисков в память, то есть размещать их в разделе, смонтированном как RAM FS. Такой подход должен был улучшить ситуацию как минимум на машинах с медленными дисками, тем более что к тому времени он уже использовался в тестах для ускорения работы еще одной СУБД, Apache Cassandra — так что изобретать велосипед не пришлось, нужно было только немного расширить и унифицировать код. Единственное “но”: старый код ориентировался только на Linux, в котором поддерживается файловая система tmpfs. На “маках” также есть аналог tmpfs, но, в отличие от Linux, в MacOS нет раздела, который создается с такой файловой системой по умолчанию, наподобие /dev/shm или /run в Linux. Его довольно просто создать, но это требовало действий разработчиков, а хотелось, чтобы все работало прозрачно. С Windows все было еще хуже: помимо того, что тесты сами по себе работали медленно (скорости им не добавлял антивирус, который проверял каждый вновь создаваемый файл), никакой поддержки RAM FS “из коробки” там нет и в помине, поэтому единственным вариантом было установить стороннее ПО, например, RamDisk, и настраивать его для работы с папкой PostgreSQL. Естественно, перспектива ручной настройки не вызывала энтузиазма у разработчиков, кроме того, как оказалось, прирост скорости был совсем незначительный (менее 5%), поэтому пришлось искать другие пути.


Пул соединений


Решение, как это часто бывает, оказалось на поверхности: в отличие от HSQLDB, которая работала в режиме in-memory database — то есть, полностью размещалась в оперативной памяти, PostgreSQL создает соединения к базе — ведь в настройках соединения в jdbc url появился порт! Так почему бы не создать пул соединений, тем самым сокращая затраты на постоянное создание новых Connection, что, как известно, весьма затратная операция? В качестве реализации был выбран c3p0 — и это было самое заметное улучшение, поскольку скорость выполнения тестов фактически вернулась к прежнему уровню HSQLDB. Добавление пула не вызвало никаких проблем, и при запуске в многопоточном режиме все по прежнему работало, и работало быстро.


Настройки PostgreSQL


Наконец мы посмотрели в сторону настроек PostgreSQL:


  • Отключение autovacuum привело к небольшому увеличению производительности;
  • Использование кэширования запросов (preparedThreshold=X) не дало заметного увеличения производительности — пробовали на значениях от 1 до 4;
  • Попытки установки разных значений для настроек памяти, например, shared_buffers и effective_cache_size также не дали никакого прироста (то есть наблюдался либо негативный эффект, либо вообще никакого). Кроме того, эффект от этих настроек слишком сильно зависит от характеристик машины, на которой поднимается база — а тесты в конечном счете запускаются на разных окружениях, отличающихся друг от друга количеством установленной и доступной RAM, поэтому мы решили оставить значения по умолчанию.

Что в итоге?


Перейдя с HSQLDB на PostgreSQL, мы не только привели тесты в соответствие с поведением, которое имеет место на продакшене. Мы также улучшили качество самих тестов, отрефакторили большинство из них, избавившись от использования захардкоженных идентификаторов сущностей, а самое главное — исправили ошибки, которые делали некоторые тесты неактуальными и даже вредными, поскольку их поведение совершенно не отражало логику кода, который используется на проде.

Как это зачастую бывает, переход был не совсем гладким: где-то мы словили очевидные баги, как в случае с порядком сортировки исходного списка и списка, получаемого из базы в результате выполнения запроса. Где-то проблемы были посерьезнее — например, пришлось покопаться в документации maven-surefire-plugin и настройках локали PostgreSQL. Но в итоге результат оправдал все затраченные усилия: теперь можно смело тестировать любой запрос, не опасаясь, что в тесте он поведет себя как-то по-особенному или не сработает вообще.

UPD: в комментариях уточнили, что yandex-qatools не является "детищем «Яндекса»" — Яндекс просто обладает правами на код — в связи с чем в статью внесены соответствующие поправки. Спасибо Lanwen за пояснение!

-->


К сожалению, не доступен сервер mySQL