Disclaimer.
Статья не содержит описание новомодных технологий или прорывы на поприще разработки. Рассматривайте её как рассказ об опыте открытия для себя мира unit тестирования.
Если вы раньше не писали unit-тесты, но хотите начать, не уверены как тестировать вашу БД и нужно ли это, не знаете как использовать моки, и для чего они, то эта статья может стать началом вашего пути.
А ещё здесь есть драконы - и это нормально.
Вместо пролога
Новый день в королевстве ознаменовался необычной новостью – в одной из провинций завёлся дракон. В детстве мы с друзьями частенько собирались послушать сказки об этих существах, но я никогда не видела их своими глазами и считала, что это просто миф. Будучи искателем приключений, я сразу поняла, что не могу упустить шанс встретиться с этим легендарным магическим существом. В моих мыслях не было эпической битвы, после которой я повешу голову побеждённого дракона на стенку в качестве трофея. Нет, я была настроена его приручить…
Будучи разработчиком, я много раз сталкивалась с ситуациями, когда что-то начинало идти не так, код вдруг переставал работать или выкидывал такие фокусы, которым позавидовал бы и Гудини. Причем происходило это в самые неподходящие моменты и в самых неожиданных местах. Как же можно обезопасить себя от таких вот неприятностей? Если вам в голову пришло "unit тестирование", то, значит, мы с вами на одной волне.
Мысль о том, что было бы полезно писать unit тесты, в моей прошлой команде звучала очень долгое время. Но, как это порой бывает, не внедрив такую практику в самом начале развития проекта, в дальнейшем оказывается сложно выкроить на неё время среди вороха бизнесовых требований: всегда находится причина, почему написание тестов откладывается в долгий ящик. Так проект и продолжает жить, разрастаясь больше и больше, а unit тесты всё также «надо как-нибудь написать».
Думаю, что многие читатели этой статьи согласятся, что в каждой истории должен быть некий поворот, определяющий дальнейший путь повествования. В моём случае он произошёл около трёх месяцев назад. Представьте себя среди степи или пустыни – классический вестерн – вокруг ничего нет, тишина, и мимо катаются по ветру перекати-поле. Примерно то же самое я ощутила, обнаружив себя на новом, только стартующем проекте. Отсутствие… в общем-то всего – это просторы для креативности и творчества, свобода выбора технологий, а главное – возможности! В голове возникла мысль: «Вот он – мой звёздный час: напишу кусок системы и ка-а-а-а-ак запилю для него тесты». С этого всё и началось.
Здесь стоит пояснить, что мой личный опыт написания тестов довольно скуден: пара интеграционных тестов на Groovy и Spock несколько лет назад. Будем считать, что его нет. Поэтому все мои дальнейшие похождения и открытия кому-то могут показаться обыденными и устаревшими, но в конце концов любой опыт – будь то чужой или личный – это опыт, и из него можно вынести что-то полезное. Поэтому, надеюсь, что в данной статье вы найдёте для себя пару интересных моментов.
Глава 1. Муки выбора
Поиски дракона - задача не из простых, а значит, мне нужно хорошенько подготовиться. Вдохновленная мыслями о предстоящем путешествии, я носилась по дому то и дело вытаскивая из разных углов вещи, которые, как мне казалось, могут пригодиться в дороге: конечно, еда и вода, ткань для тента, лук со стрелами, лечебные травы, защитные руны, gps-навигатор... Стоп, что? Кажется, я промахнулась на пару столетий. Забыли про навигатор, возьмём карту. Так-то лучше. В итоге оказалось, что возьму я с собой почти всё, что нашлось в доме. Нет, так не годится. Начну сначала, и первым делом отвечу себе на вопрос - что из этого мне действительно нужно?..
В качестве подопытного кролика для создания тестов был выбран один из микросервисов, отвечающий за получение сводных данных по объектам системы. Выгрузка этих данных должна осуществляться в таблицы с динамическим набором столбцов по динамически формируемым фильтрам. Также в этих таблицах присутствуют так называемые «счётчики»: выгружаемые данные делятся на разные группы по определённым признакам, и счётчики указывают на количество объектов, принадлежащих той или иной группе.
Имея опыт разработки подобных задач, я примерно знала, с какими проблемами и сложностями здесь предстоит столкнуться, а, следовательно, целью было постараться их избежать.
В прошлый раз мы использовали Specification из Spring Data для формирования динамических запросов по сущностям. Но я решила, что это всё же не лучший выбор применительно к данной задаче, поскольку Specification, хоть и является полезным инструментом, но всё же может сгенерировать не самые оптимальные запросы в ряде случаев. Поэтому я обратила своё внимание на другую технологию, с которой имела опыт работы ранее, а именно - JOOQ.
Немного про JOOQ
JOOQ - инструмент для работы с SQL в Java. JOOQ предоставляет удобный DSL для составления запросов, а также генератор классов. Другими словами, если JPA позволяет создавать таблицы в БД на основе описания классов, то JOOQ сгенерирует классы на основе метаданных БД. Более подробно о JOOQ можно почитать по ссылке выше.
Все таблицы БД (в нашем случае это Postgres 11) в проекте создавались посредством Liquibase скриптов. Для большего удобства обращения к данным были составлены специализированные view – своя на каждый вид таблицы, представленной в интерфейсе.
Поскольку я не могу отрицать удобство использования JPA с JPQL и автоматического создания запросов на основе имени метода, я не стала заменять абсолютно всё общение с БД на JOOQ. В методах, где нет формирования запросов по динамическим условиям, я оставила Spring Data, подружив эти две технологии, которые в общем-то прекрасно вместе уживаются.
Что касается тестов (да-да, наконец-то мы поговорим и о них), то, недолго думая, я взяла JUnit5 и Mockito, для тестов методов, не требующих обращения к БД. Но тут мы закономерно подошли к вопросу – а как же тогда тестировать работу с БД? На просторах Интернета гуляет множество статей на эту тему, я же упомяну ключевые моменты, которые выделила для себя:
Во-первых, тестировать БД в целом нужно (спасибо, Кэп). И делать это надо без моков, так как их использование вместо реальных запросов к БД не даст нам никакой ценной информации о корректности тестируемого функционала.
Во-вторых, для тестирования лучше использовать ту же БД, что на вашем продакшене, поскольку внутренние механизмы разных БД могут различаться. Таким образом те тесты, которые успешно проходят, скажем, на H2, Oracle или любой другой БД, могут упасть на Postgres в силу фактора Х.
В-третьих, лучше использовать отдельную БД, данные которой не зависят от внешних факторов и актуальны для ваших тестов в каждый момент времени.
Исходя из этого, мой взгляд обратился к способам подключения встроенной БД Postgres в тестируемый модуль. Здесь я хочу упомянуть одну статью, которая в этом значительно помогла, а именно - Шесть советов об использовании PostgreSQL в функциональных тестах.
Изначально, аналогично коллегам, я хотела использовать OpenTable. Однако, прочитав описание Zonky и сравнив предлагаемый функционал, а также частоту обновлений данных библиотек, решила переключить своё внимание именно на вторую.
Если говорить вкратце, то Zonky позволяет подключить к модулю embedded Postgres (а также MSSQL, MySQL или MariaDB) нужной вам версии для создания тестов на БД, наиболее приближенных к работе с вашей продуктовой. Также важным для меня моментом оказался тот факт, что она совместима с Liqubase (а также с Flyway), что позволило мне генерировать схему тестовой БД на основе уже написанных скриптов.
Инструментом сборки в проекте является Gradle, что меня вполне устраивало, поэтому здесь, можно сказать, всё решилось само собой.
Структура проекта выглядит следующим образом (warning: не пугайтесь, классов много, но ко всем ним далее в статье сделаны пояснения) :
Структура проекта
├── changelog
├── changelog-master.xml
└── modules
├── view-module
│ ├── src
│ │ ├── main
│ │ │ ├── java
│ │ │ │ ├── views
│ │ │ │ │ ├── builder
│ │ │ │ │ │ ├── ConditionBuilder.class
│ │ │ │ │ │ ├── SortBuilder.class
│ │ │ │ │ ├── factory
│ │ │ │ │ ├── CounterFactory.class
│ │ │ │ │ ├── repository
│ │ │ │ │ │ ├── dao
│ │ │ │ │ │ │ ├── SomeViewDao.class
│ │ │ │ │ │ └── SomeViewEntityRepository.class
│ │ │ │ │ ├── entity
│ │ │ │ │ ├── SomeViewEntity.class
│ │ │ │ │ ├── service
│ │ │ │ │ ├── SomeViewService.class
│ │ └── test
│ │ ├── java
│ │ │ ├── checker
│ │ │ ├── FilterParametersChecker.class
│ │ │ ├── factory
│ │ │ │ ├── TestViewFilterFactory.class
│ │ │ │ └── TestCounterFactory.class
│ │ │ └── service
│ │ │ ├── TestDatabase.class
│ │ │ ├── TestCounterService.class
│ │ │ ├── TestJpaService.class
│ │ └── resources
│ │ ├── csv
│ │ │ ├── ...
│ │ ├── data
│ │ │ ├── filter-test-cases.csv
│ │ ├── application.yml
│ │ ├── changelog-master-test.xml
│ │ ├── schema-test.xml
Итак, помня обо всём необходимом (JUnit5, Mockito, Zonky, Liquibase, JOOQ, JPA, Gradle), отправляемся в приключение.
Глава 2. Встретились как-то в таверне…
Каждому герою нужны помощники в его эпическом квесте. Где же их искать? На просторах королевства среди густых лесов есть одно местечко – таверна «Maven Central», где частенько можно найти «наёмника», готового выполнить за вас рутинную (а иногда не очень) работу. Вот сюда-то я и направлюсь в первую очередь...
Для тестов нам необходим набор зависимостей, которые будут одинаковы в любом из модулей, поэтому я выделила их в общий блок:
build.gradle
ext {
test_commons = [ "org.projectlombok:lombok:1.18.20",
"org.junit.jupiter:junit-jupiter-engine:5.7.1",
"org.mockito:mockito-junit-jupiter:3.10.0",
"org.springframework.boot:spring-boot-starter-test:2.5.0",
"org.liquibase:liquibase-core:4.3.5",
"io.zonky.test:embedded-postgres:1.2.10",
"io.zonky.test:embedded-database-spring-test:2.0.0"
]
}
Хочется обратить отдельное внимание на следующие библиотеки:
org.liquibase:liquibase-core:4.3.5 – Зависимость на Liquibase нужной нам версии
io.zonky.test:embedded-postgres:1.2.10 – Зависимость, необходимая для инициализации dataSource контекста
io.zonky.test:embedded-database-spring-test:2.0.0 – Зависимость Zonky, специализированная под работу со Spring. Начиная со 2 версии библиотеки версией Postgres по умолчанию стала 11, что как раз идеально нам подходит, но, при желании, её можно изменить.
Далее в тестируемом модуле в build.gradle добавляем зависимость:
build.gradle
dependencies {
…
testImplementation test_commons
}
Поздравляю, «наёмники» завербованы успешно!
Ну вот и всё. Преисполненная решимости я оседлала своего коня и уже была готова отправиться в путь-дорогу. Но постойте-ка. Кажется, с ним что-то не так… Да и новая команда как-то с осторожностью посматривает друг на друга, готовая в любой момент схватиться за меч. Да, пожалуй, всё не так просто…
Для тех, кто знаком со Spring, не секрет, что корректная настройка конфигураций является одним из ключевых моментов (без этого лошадка с места не стронется).
Начнём с нашего application.yml файла. В общем и целом, все настройки, указанные в файле, не отличаются от конфигурации вашего приложения. Нужно лишь добавить два новых блока:
application.yml
spring:
jooq:
sql-dialect: postgres
liquibase:
change-log: "changelog-master-test.xml"
zonky:
test:
database:
provider: zonky
type: postgres
Первый блок позволяет задать файл, в котором мы укажем скрипты Liqubase, на основе которых будет создаваться тестовая БД. Второй же задаёт значения для автоконфигурации встроенной БД.
Для любых тестов нужны данные. Как уже говорилось ранее, Zonky прекрасно работает с Liquibase, поэтому, недолго думая, я создала файл "changelog-master-test.xml", который чуть выше мы указали в настройках application.yml со следующим содержанием:
changelog-master-test.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="<http://www.liquibase.org/xml/ns/dbchangelog>"
xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>"
xsi:schemaLocation="<http://www.liquibase.org/xml/ns/dbchangelog>
<http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd>">
<include relativeToChangelogFile="true" file="changelog/changelog-master.xml"/>
<include relativeToChangelogFile="true" file="schema-test.xml"/>
</databaseChangeLog>
Как вы можете заметить, здесь я делаю импорт двух файлов. Первый - changelog-master.xml – это схема для наката нашей продуктивной БД. Таким образом можно гарантировать что схема тестовой БД всегда будет актуальна и будет соответствовать «боевой». Второй файл - schema-test.xml – это скрипты, загружающие в таблицы исключительно тестовые данные, хранящиеся в папке csv в тестовых ресурсах.
Но погодите, почему при запуске ликви-скрипты упорно продолжают падать?
Дело в том, что в нашем проекте пакет changelog с Liquibase скриптами находится в корне проекта, и при попытке найти файл changelog-master.xml мы получаем ошибку: liquibase.exception.SetupException: The file changelog/changelog-master.xml was not found in Spring resources.
Справиться с этой бедой нам поможет следующая секция в скрипте сборки, копирующая скрипты "боевой" БД в ресурсы модуля перед запуском тестов:
build.gradle
task copyTestResources(type: Copy) {
from "$rootDir/changelog"
into "build/resources/test/changelog"
}
test.dependsOn(copyTestResources)
Кажется, этим ребятам действительно нужен был тим-билдинг. Теперь, когда все разногласия улажены, лошади накормлены, а за лесом виднеются клубы дыма (что может значить либо случайный пожар, либо намеренный поджог, либо близость дракона), наша команда направляется навстречу неизведанному…
Глава 3. Дорогу осилит идущий
Не прошло и дня с начала нашего приключения, как на пути возникло препятствие - огромная глубокая пропасть рваной раной разверзлась посреди леса. Один взгляд в зияющую темноту и сразу понятно, что падать очень долго, и приземление явно будет не из приятных. Выхода два: или искать другой обходной путь, который, может и не существует, или строить мост...
Если вы, как и я, используете Gradle в своём проекте, то настройка JOOQ может оказаться для вас не самым приятным занятием. Я не буду приводить конкретные примеры ошибок, с которыми сталкивалась, просто потому что их было много, и, честно говоря, я их не фиксировала, поэтому, если здесь есть отважные авантюристы, ищущие приключений - приглашаю поэкспериментировать самостоятельно.
С Maven же в свою очередь, у JOOQ каких-то явных проблем не возникает (основываясь на прочитанном мной на просторах Интернета), так что если в вашем проекте используется именно он, и вам интересно посмотреть, как же оно выглядит, то об этом можно почитать в статье JOOQ и его кроличья нора. Как выжить без Hibernate. Лично на мой взгляд здесь очень подробно описано всё, что нужно знать в начале работы с этой библиотекой, поэтому, считаю, что эта статья может быть полезной.
Вернёмся к нашим баранам. Создадим файл jooq.gradle в тестируемом модуле. Укажем в нём параметры для генерации классов моделей на основе метаданных БД и создадим соответствующую задачу для Gradle:
jooq.gradle
import org.jooq.codegen.GenerationTool
import org.jooq.meta.jaxb.Database
import org.jooq.meta.jaxb.Generator
import org.jooq.meta.jaxb.Jdbc
import org.jooq.meta.jaxb.Target
ext.db = [
url: 'jdbc:postgresql://{тут_мог_бы_быть_ваш_ip_адрес}:5432/test_database',
user: 'postgres',
password: 'postgres',
schema: 'public',
driver: 'org.postgresql.Driver',
jooqDbImpl: 'org.jooq.meta.postgres.PostgresDatabase',
packageName: 'entitypackage.jooq.db'
]
ext.genpath = new File("${projectDir}/src/main/java")
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath "org.jooq:jooq:3.14.11"
classpath "org.jooq:jooq-meta:3.14.11"
classpath "org.jooq:jooq-codegen:3.14.11"
classpath "org.postgresql:postgresql:42.2.20"
}
}
sourceSets.main.java.srcDirs += genpath.toString()
task generateDbEntitiesJooq() {
doLast {
if (!genpath.exists()) {
genpath.mkdirs()
}
org.jooq.meta.jaxb.Configuration configuration = new org.jooq.meta.jaxb.Configuration()
.withJdbc(new Jdbc()
.withDriver(db.driver)
.withUrl(db.url)
.withUser(db.user)
.withPassword(db.password)
)
.withGenerator(new Generator()
.withDatabase(new Database()
.withName(db.jooqDbImpl)
.withIncludes("SOME_VIEW")
.withExcludes("")
.withInputSchema(db.schema)
)
.withTarget(new Target()
.withPackageName(db.packageName)
.withDirectory(genpath.toString())
)
);
GenerationTool.generate(configuration);
}
}
task deleteGeneratedCode(type: Delete) {
doLast {
delete genpath
}
}
В первую очередь в секции ext.db я указала данные для подключения к БД, которая будет выступать в качестве целевой картины. В целом эта секция опциональная и сделана чисто для удобства, можно указывать эти параметры напрямую в конфигурации jooq (task generateDbEntitiesJooq).
Обращу отдельное внимание, что так как в нашем проекте мы используем JOOQ лишь для части функционала, то нам не нужно генерировать модели для всех таблиц, существующих в БД. Поэтому при конфигурации генератора я указала лишь те сущности, которые участвуют в нашем тестируемом функционале. В генераторе за это отвечает параметр
.withIncludes("SOME_VIEW")
Если вам нужно сгенерировать несколько сущностей, просто укажите их списком, разделив через | : "some_view|some_entity|one_more_table"
Как вы можете заметить, содержимое тасков generateDbEntitiesJooq (генерация сущностей) и deleteGeneratedCode (удаление сгенерированных сущностей) обернуты в doLast, что позволяет нам явно указать, что код данных задач должен выполняться лишь тогда, когда мы напрямую их запустили (мы ведь не хотим, чтобы генерация и удаление классов происходили каждый раз при пересборке проекта?)
Далее необходимо сконфигурировать Gradle с добавлением jooq.gradle для тестируемого модуля.
build.gradle
buildscript {
...
}
apply from: 'jooq.gradle'
dependencies {
...
testImplementation test_commons
}
test {
useJUnitPlatform()
}
task copyTestResources(type: Copy) {
from "$rootDir/changelog"
into "build/resources/test/changelog"
}
test.dependsOn(copyTestResources)
Поздравляю, наши новые таски были успешно добавлены!
Лёгкие пути - не для нас. Засучив рукава, мы принялись за строительство моста. Не знаю, сколько времени на это ушло, но результат был очень даже неплох - во всяком случае, мы могли спокойно проехать через разлом, не боясь рухнуть вниз. Считаю это маленькой победой. Убедившись, что команда благополучно перебралась через расщелину, я вновь углубилась в чащу леса...
Глава 4. {…} и янтарные очи дракона отражает кусок хрусталя {…}
Мы долго ехали сквозь дремучие леса королевства, то теряя след, то снова находя его, но неизменно сгорая от нетерпения. За каждым поворотом нас ждали опасности: то нападут лесные разбойники, то атакует рой маленьких, но так больно жалящих ядовитых ос, чьи укусы вызывают страшные галлюцинации. Но всё это время перед глазами у нас стояла цель – сияющая на солнце чешуя, могучие лапы, крылья, закрывающие собой небо и мощь легендарного создания. И мы шли вперёд, как бы сложно ни было, какие бы преграды нас ни ожидали, мы знали – оно того стоит…
Перейдём непосредственно к нашему тестовому классу, для простоты назовём его TestDatabase. Здесь мы хотим проверить корректность выборки данных из view на основании входящих фильтров.
Не буду мучить вас рассказами о длительном пути подбора корректных аннотаций для запуска класса и просто покажу конечный результат:
TestDatabase
@JooqTest
@AutoConfigureEmbeddedDatabase
@ContextConfiguration(classes = {SortBuilder.class, ConditionBuilder.class, SomeViewDao.class, FilterParametersChecker.class})
@DisplayName("SomeViewDao Test Service")
public class TestDatabase {
private static final Pageable defaultPageable = PageRequest.of(0, 20);
@Autowired private SomeViewDao someViewDao;
@Autowired private FilterParametersChecker filterParametersChecker;
…
}
@JooqTest – Аннотация позволяет сконфигурировать все сущности для работы с JOOQ
@AutoConfigureEmbeddedDatabase – Аннотация, переопределяющая DataSource на основании встроенной БД
@DisplayName – Добавим сахарку и напишем читабельное название для нашего теста.
@ContextConfiguration – Аннотация, позволяющая импортировать только нужные в текущем тесте бины. В нашем случае это:
SomeViewDao – Dao класс, хранящий методы по работе с БД с таблицей some_view;
SortBuilder – Класс с методами по созданию условий сортировки;
ConditionBuilder - Класс с методами по созданию условий выборки;
FilterParametersChecker – Дополнительный класс, содержащий методы проверки корректности выгруженных данных, сверяя в отдельности каждый параметр с параметрами входных данных.
Реализацию данных классов опустим.
На этом конфигурация тестового класса закончена.
Как я говорила ранее, мой опыт с тестами можно сравнить с дыркой от бублика, поэтому первым, что я решила сделать, было «Окей, Гугл…». И тут меня снова выручил Хабр со статьей 10 интересных нововведений в JUnit 5. Когда я в прошлый раз пыталась писать тесты, мне действительно нравился Spock в качестве инструмента для написания тестов (здесь я почувствовала себя обязанной вставить шутку про «live long and prosper», ибо ну какие-то вещи просто стали традицией). Он давал возможность использовать один метод для тестирования сразу нескольких сценариев, указывая тестовые данные списком в блоке where (подробнее о синтаксисе и в целом фреймворке можно почитать здесь: https://spockframework.org/). Для меня было приятным открытием, что JUnit5 позволяет воспроизвести аналогичное поведение благодаря аннотации @ParameterizedTest.
Источником тестовых сценариев я выбрала csv файл (filter-test-cases.csv), но в принципе здесь каждый волен выбирать то, что ему удобнее: файл, енам или же просто массив значений – JUnit5 поддерживает каждый из этих вариантов.
Прежде чем всё заработало корректно, мне не раз встречались и NullPointerException, и множество NoSuchBeanDefinitionException, и даже MockitoException (но об этом чуть позже). Каждый запуск теста приносил с собой всё новые и всё более загадочные проблемы. Тут я хочу сказать большое спасибо моим коллегам-разработчикам за то, что выслушивали всю мою ругань в адрес тестов и помогали, чем могли, с решениями проблем, когда я совсем заходила в тупик.
В общем и целом, мой тестовый метод выглядел примерно следующим образом:
testSomeDao
@ParameterizedTest
@CsvFileSource(resources = "/data/filter-test-cases.csv", delimiter = ';')
public void testSomeDao(String filtersString) {
List<ViewFilter> filters = convertStringToList(filtersString, ViewFilter.class);
Map<String, List<Object>> parameterFilters = new HashMap<>();
filters.forEach( filter -> {
filter.getColumns().forEach(column -> { parameterFilters.put(column.getColumnName(), filter.getValues());});
});
List<SomeViewRecord> records = someViewDao.getRecordByFilter(filters, defaultPageable);
for (SomeViewRecord record : records) {
parameterFilters.keySet().forEach( columnName -> {
boolean isContainValue = filterParametersChecker.isEqualToFilter(columnName, record.get(columnName), parameterFilters);
isTrue(isContainValue, format("Column %s expected to contain values = %s. Current values = %s", columnName,
parameterFilters.get(columnName).stream().map(String::valueOf).collect(joining(COMMA)),
});
}
}
Сконфигурировав DataSource с помощью аннотации @AutoConfigureEmbeddedDatabase, а DSLContext JOOQ-а – с помощью @JooqTest, я перенаправила все запросы с нашей реальной БД на встроенную, созданную Zonky. Все тестовые данные были залиты в неё в момент создания, и я точно знала, какие результаты и в каком количестве должна выдавать Dao в зависимости от входных данных. Это позволило мне сделать максимально точную проверку – как по количеству выгружаемых записей из БД, так и по совпадению каждого параметра, переданного во входном фильтре с полученными записями.
Вот и сказочке конец, а кто слушал…
«Так, стоп! Что за надувательство?», спросите вы. - «А где же JPA, где обещанный Mockito?». Вполне справедливо, обещала – показываю.
Глава 5. Конец пути?..
Солнце давно скрылось за горизонтом, а мы продолжали пробираться сквозь темноту и холод ночи, согретые лишь надеждой на то, что в скором времени доберёмся до цели. И будто ответом на наши мысли где-то вдалеке, меж стволов деревьев, задребезжал бледный свет. Преисполненные радости мы устремились к нему, не разбирая дороги. Свет становился всё ярче и ярче, пока на миг всё вокруг не стало ослепительно белым. Протерев глаза, я не могла поверить в увиденное. Мы стояли перед огромной неоновой вывеской «До дракона осталось как до Пекина пешком». Да, совсем не тот конец, на который я рассчитывала. Хорошенько выругавшись, я обратилась к своим спутникам:
- Я знаю, что эта дорога была изнурительна и долга, а что нас ждёт впереди… Да кто его знает, честно говоря. А поэтому я более не держу тех их вас, кто решит, что дальше нам не по пути.
Не все остались с нами, лишь те, кто посчитал, что их судьба лежит впереди…
Итак, теперь забудем про JOOQ и вернемся к Mockito и JPA.
Наша следующая остановка – класс TestCounterService. Здесь мы будем проверять корректность счётчиков, которые подсчитывают, сколько записей из нашей выгрузки соответствует тому или иному критерию. Данный функционал не связан с работой БД напрямую (для unit-тестов нам не важно, корректно ли были выгружены данные, важно лишь, чтобы они правильно посчитались), значит и делать запрос к БД нам не нужно. Вместо этого используем моки, чтобы убрать зависимость результата теста от работы БД.
TestCounterService
@ExtendWith({MockitoExtension.class, SpringExtension.class})
@SpringBootTest(classes = { CounterFactory.class, TestViewFilterFactory.class, TestCounterFactory.class })
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class TestCounterService {
@Autowired private TestViewFilterFactory testViewFilterFactory;
@Autowired private TestCounterFactory testCounterFactory;
@Autowired private CounterFactory counterFactory;
@MockBean private SomeViewDao someViewDao;
private static UserEntity defaultUser;
…
}
@ExtendWith – Позволяет использовать расширения в тестах. В нашем случае это расширения для Mockito и Spring в контексте тестов;
@TestInstance – Позволяет определить моки сразу для всего класса единожды, а не перед каждым тестом;
@SpringBootTest – Загружает указанные бины в контекст при запуске теста. Здесь это следующие бины:
CounterFactory – Реальная фабрика счётчиков, которую мы будем тестировать;
TestViewFilterFactory – Генератор тестовых фильтров;
TestCounterFactory – Тестовая фабрика счётчиков, которая будет имитировать объекты, возвращаемые запросом в БД.
Проинициализируем мок-объекты. Создадим мок бин – SomeViewDao – класс, делающий запросы к БД, и дефолтного пользователя, который будет выступать в качестве одного из фильтров для наших запросов. Определим поведение мок-объекта в методе initMocks() в зависимости от входных данных:
initMocks
@BeforeAll
static void initStatic() {
defaultUser = createDefaultUser(UUID.randomUUID());
}
@BeforeEach
void initMocks() {
lenient().when(someViewDao.getCounterInfoByFilter(isNull())).thenReturn(testCounterFactory.createMultipleCounterDatabaseInfo(defaultUser));
lenient().when(someViewDao.getCounterInfoByFilter(eq(emptyList()))).thenReturn(testCounterFactory.createEmptyCounterDatabaseInfo());
lenient().when(someViewDao.getCounterInfoByFilter(argThat(filter -> filter != null && filter.size() != 0))).thenReturn(testCounterFactory.createSingleCounterDatabaseInfo(defaultUser));
}
Здесь можно почитать про lenient.
И, конечно, напишем сам тест. В качестве примера я взяла один из сценариев, когда фильтр во входных данных отсутствует:
getEmptyListCounter
@Test
void getEmptyListCounter(String todayDate) {
try {
LocalDate todayLocalDate = LocalDate.from(YYYY_MM_DD_HH_MM_SS_DASH_FORMATTER.parse(todayDate));
MockedStatic<LocalDate> mock = mockStatic(LocalDate.class, CALLS_REAL_METHODS);
mock.when(LocalDate::now).thenReturn(todayLocalDate);
List<CounterDatabaseInfo> counterInfo = someViewDao.getCounterInfoByFilter(emptyList());
Counter counter = counterFactory.createCounter(counterInfo, defaultUser);
notNull(counter, "counter is null");
isTrue(allCount == counter.getAllCount(),
format("All count %s not equals expected %s", counter.getAllCount(), boardAllCount));
isTrue(firstTypeCount == counter.getFirstTypeCount(),
format("First type count %s not equals expected %s", counter.getFirstTypeCount(), firstTypeCount));
isTrue(secondTypeCount == counter.getSecondTypeCount(),
format("Second type count %s not equals expected %s", counter.getSecondTypeCount(), secondTypeCount));
} finally {
mock.close();
}
}
В представленном коде мы делаем вызов метода getCounterInfoByFilter из тестовой Dao (someViewDao), которая содержит формирование запроса к БД на основании входных данных.
Хочу обратить отдельное внимание на переменную mock - мок на статический метод now() класса LocalDate. Внутри метода getCounterInfoByFilter при формировании условий запроса присутствует вызов LocalDate.now(). Нам необходимо при вызове этого метода подменять текущую дату на ту, которая нужна при прогоне того или иного сценария (например, вам нужно выгрузить все данные, созданные не сегодня, что как раз выдает LocalDate.now(), а в какую-то определенную дату, в зависимости от ваших тестовых данных).
Начиная с версии 3.4.0 библиотека Mockito может работать и со статическими методами, что раньше было возможно только с PowerMockito. Важным моментом является то, что после окончания теста, будь оно успешным или нет, нам нужно вызывать метод mock.close(), чтобы дальнейшие сценарии не упали с ошибкой:
MockitoException: static mocking is already registered in the current thread.
Таким образом, с помощью моков мы проверили сам механизм подсчета данных, убрав зависимость результата выполнения теста от корректности выгрузки данных из БД.
Для JPA я покажу лишь настройку аннотаций, так как примеры тестов здесь ничем не отличаются от двух предыдущих:
TestJpaService
@DataJpaTest(showSql = false)
@AutoConfigureEmbeddedDatabase
@ContextConfiguration(classes={SomeViewEntityRepository.class, SomeViewService.class})
@EnableJpaRepositories(basePackages = {"views.repository"})
@EntityScan("views.entity")
public class TestJpaService {
@Autowired private SomeViewEntityRepository someEntityRepository;
@Autowired private SomeViewService someViewService;
…
}
@DataJpaTest – Позволит нам отключить автоконфигурацию и сконфигурирует только те компоненты JPA, которые относятся непосредственно к тестам.
@EnableJpaRepositories – Подгрузит классы репозиториев из указанного пакета в бины.
@EntityScan – Подгрузит классы энтити из указанного пакета в бины.
Вуаля! Пара строк – и всё работает. Не правда ли похоже на магию?..
Первые лучи солнца озарили поляну, когда мы, уставшие, прошедшие через мрак ночи, решили сделать привал. Но не прошло и часа, как вдруг поднялся сильный ветер, уши будто заложило, и единственным звуком, прорывающимся сквозь этот барьер, был громкий звериный рёв. Нет сомнений – это был он. Подняв голову вверх, я замерла, пораженная видом: он оказался таким, как я себе представляла – сверкающие золотистые чешуйки, огромные мощные крылья и величие в каждом его движении. В этот момент я поняла, какими наивными были мои планы приручить дракона. Ведь что я о них знаю? Старые предания и легенды? Нет. Здесь нужно время изучить и понять его, и быть может тогда…
Вместо эпилога
Этот путь был тернист и полон испытаний моего терпения, но в конце концов сказки должны заканчиваться хорошо. А ещё они должны чему-то учить. Для меня это было именно так. Я впервые встретилась лицом к лицу с unit тестированием, в частности с тестированием базы данных. Корректная настройка JOOQ с Gradle, конфигурация Spring, Zonky и Liquibase, замена реальных объектов на моки с Mockito и, конечно, само использование JUnit5 - я постаралась описать каждый шаг на пути к заветной галочке: Test passed. На данный момент в нашей системе успешно проверены порядка 500 сценариев по различному функционалу, и впереди ещё много новых открытий связанных с миром тестирования.
Надеюсь, что для тех, кто прочитал эту статью до конца, она также была полезна.
P.S. Возможно, какие-то вещи, представленные здесь, были не самыми оптимальными. Поскольку это был мой первый опыт написания тестов, я ничего не исключаю. А потому буду рада, если знающие люди поделятся своей экспертизой и наставят меня на путь истинный =) Любые мнения и комментарии приветствуются до тех пор, пока они не голословны)
P.P.S. Все рисунки с драконами принадлежат KoDa