Привет, Хабр! 

Я, Михаил Герасимов, инженер РСХБ-Интех. Уже два года занимаюсь автоматизацией тестирования, и за это время успел написать (и переписать) немало SQL-запросов. Вместе с моим коллегой Михаилом Палыгой мы развиваем инструменты для автоматизированного тестирования, и сегодня расскажем вам о том как мы справляемся с построением сложных SQL-запросов и проверкой объектов в базе данных, на примере нашей библиотеки CheckMateDB для автоматизации тестирования банковской системы ЦФТ-Банк. 

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

Мы создали иерархию классов CriteriaBasic и Table для удобного описания критериев поиска данных в базе, используя паттерн fluent interface. Также мы разработали кастомные классы проверок на базе AssertJ с поддержкой Allure-шагов, которые позволяют проверять сложные многоуровневые объекты с возможностью погружения во вложенные структуры. Для облегчения рутинной работы создали плагин, автоматически генерирующий классы DTO и Table на основе структуры базы данных. Библиотека интегрирована с Hibernate через DaoCommon, что обеспечивает удобное выполнение SQL-запросов и управление сессиями. Результатом стало существенное улучшение читаемости тестов, повышение переиспользуемости кода, стандартизация подхода к тестированию и создание информативных Allure-отчетов. 

Мы тестируем систему ЦФТ-банка, где тестирование данных — это отдельная наука. Тестовые данные разбросаны по разным таблицам, и найти их — это целое приключение. В процессе работы сталкиваемся с некоторыми особенностями. Постоянная валидация данных в базе — сотни строк SQL-кода, которые постоянно дополняется новыми критериями — приводит к дублированию кода, из-за чего тесты становятся тяжело поддерживаемыми. 

Также стоит учитывать, что наши заказчики — регрессионные тестировщики. Для них важно, чтобы Allure-отчёт был максимально похож на шаги в тест-кейсе, чтобы не пришлось гадать, что же именно произошло в тесте. Также этот инструмент должен решать задачи не только на уровне базовых проверок, но и на уровне кастомных бизнес-логик. 

Какие проблемы мы вообще решаем? Автоматизированное тестирование в банковской системе ЦФТ — это не просто «проверить, что система работает». Нам нужно работать с тестовыми данными, выполнять действия и в конце проверять корректность результата. Ведь наша цель — не зеленая галочка, а протестировать систему в целом. И для этого нужно найти такие данные, которые точно отражают реальную ситуацию. А после теста ещё важно убедиться, что все объекты сохранились корректно. 

Раньше всё было по-старому... Чтобы найти нужные данные для тестов, мы писали SQL-запросы вручную. Да-да, вручную — как в 90-е. Но из-за того подхода код дублировался, поддерживать его становилось всё сложнее, а тесты читались так, что начинали напоминать древнегреческие трагедии. И, конечно, не обходилось без жестких проверок, которые совершенно не учитывали бизнес-правила. Получалась такая картина: тесты для тестов, которые были не только трудными для понимания, но и для изменения.

Поиск тестовых данных для прохождения автотеста

Тест состоит из трех этапов: первый — поиск данных, второй — воздействие на эти данные, а третий — проверка, что данные изменились как нужно, согласно тест-кейсу. В идеале все выглядит так: разработчик создал инструмент - фреймворк для тестирования, написал автотест, залил код в гит, прогнал тест в Jenkins, тест прошел — и все счастливы.

Но тут подключается наш заказчик. Он смотрит на результат и говорит: «Все здорово, но... есть еще пару критериев по договору». Разработчик немного напрягся, но, вроде бы все было просто — он пишет новый SQL-запрос, тест прошел, всем хорошо. Наш заказчик обрадовался результату. Но нет предела совершенству, надо добавить еще немного критериев…

И вот тут начинаются настоящие танцы с бубном. Но всё же, новый запрос появился, тест прошел, все снова счастливы. Как правило, всегда добавляются новые критерии. И это не предел: тестировщик будет добавлять и менять критерии поиска до конца жизни приложения. А разработчик в очередной раз напрягаться, но всё делать. 

Разработчик создаёт настоящий шедевр: десяток JOIN, пара десятков критериев и комментариев, которые могут быть использованы как учебник по SQL для будущих поколений.

Конкретный пример того, что раньше у нас было на проекте: 

public class DocumentAccount extends DbRequest {
 
     public <Z> Z getResult() {
         String query = String.format(
                 "select C_MAIN_V_ID acc " +
                         "from ibs.z#ac_fin acf " +
                         "where acf.c_main_v_id like %s " +
                         "and acf.c_client_r = (select C_CLIENT from ibs.z#rko rko where id = %s)",
                 accountType,
                 rkoId
         );
         return dbWork(query);
     }
 }

В каждом запросе к базе данных создавался отдельный класс, и в нем хардкодился SQL-запрос. И что в нем возвращалось? Не объект, а просто ID. Ну, как вам такой подход? Всё так, как мы любим — просто и понятно. Тут сразу видны проблемы, о которых мы уже говорили: 

  1. Мы получаем монолит, в котором запросы к базе прописываются как обычные строки. Что в них можно понять, кроме того что «это строка»? Ничего.

  2. Минималистичная выдача. Например, если запрос возвращает только ID, то на выходе у нас получается совсем ничего. Ну что с этим ID делать? Это как найти иголку в стоге сена, только иголка ещё не говорит, где она прячется.

  3. Дублирование кода. У нас могут встречаться очень похожие друг на друга запросы, но отличающиеся в небольших деталях, например при поиске договора обслуживания мы - где то ищем его по Филиалу, где-то по Департаменту и так далее. И на каждый такой слегка отличающийся запрос приходилось создавать свой класс. Совершенно неудобно.

  4. Не было единства в стиле написания запросов. Хотя у нас и был определен кодстайл, но все равно у каждого разработчика получалось по‑своему, из-за этого код запросов был не унифицированным.

  5.  Для написания запросов к БД нужно уверенно знать структуру этой самой БД. Новичкам при подходе когда запросы приходится писать вручную будет очень и очень непросто.

Проверка данных после выполнения теста

После выполнения теста нам нужно убедиться, что объект в базе данных изменился корректно. Но стандартные проверки слишком жесткие. Они требуют точного совпадения значений, а это в реальной жизни не всегда случается. Кроме того, они не дают достаточного контекста, из-за чего при падении теста сложно понять, где именно произошла ошибка и что конкретно мы пытались проверить на этом шаге.

Поначалу каждый разработчик писал проверки, как считал нужным. Пробовались различные подходы. Например, на самом раннем этапе проверки производились по наличию ID в базе данных, без проверки самих данных. Очевидно, это не являлось полноценной проверкой.

public void validateObjectInDatabase(Object expectedObject, String objectId) { 
 
     // Здесь мы делаем запрос в базу данных по ID 
     Object actualObject = database.getObjectById(objectId); 
 
     // Проверяем, что объект существует в базе 
     Assertions.assertThat(actualObject).isNotNull(); 
 
     // Далее проверяем, что поля объекта соответствуют ожидаемым
     Assertions.assertThat(actualObject.getField1()).isEqualTo(expectedObject.getField1());      
     Assertions.assertThat(actualObject.getField2()).isEqualTo(expectedObject.getField2()); 
 
     // И так далее для всех нужных полей... 
 }

Следующий виток эволюции у нас был такой: разработчик создавал класс Helper с методами для проверки данных, а потом этот класс наследовался в классе с тестом. Простенько, но не очень эффективно.

public class MyTestHelper {

    @Step("Проверить добавление группы ТМЦ")
    public Dto_TmcGroup checkNewTmcGroupCreatedById(Long newGrupId) {

        Dto_TmcGroup actualResult = serviceTmcGrup.getTmcGroupById(newGrupId);

        assertThat(actualResult).isNotNull();

        return actualResult;
    }
}

Так как проверки писались под конкретные тесты, невозможно было полноценно переиспользовать код. Многие методы в разных классах дублировались, ведь никто толком не знал, кто и где написал нужную проверку. А там, где и было возможно переиспользование, начали появляться длинные цепочки наследования. Типичная история:«Там, где‑то в одном Helper'е лежит нужная мне проверка, унаследуюсь‑ка я от него. А еще в другом классе тоже есть пара нужных мне методов, надо бы и его встроить в цепочку наследования». Всё это превращалось в такую запутанную структуру, что проще было бы уже написать проверку вручную.

К тому же подход к написанию этих методов проверки так и не стал стандартизирован. Творческая свобода, конечно, хорошо, но вот если бы все проверки были в едином стиле и с одинаковым подходом, было бы намного удобнее. Мы пробовали разные подходы и в итоге остановились на использовании библиотеки AssertJ. Но тут есть один нюанс...

Поддержка единого стиля Allure-отчётов

В нашем проекте заказчики — это регрессионные тестировщики. И, как вы понимаете, им очень важно, чтобы Allure-отчёты были понятными и напоминали шаги их собственных ручных тестов.

Когда проверки прописаны прямо в тестах, поддерживать единый стиль становится проблемой. Каждый тестировщик пишет шаги по-своему, и отчёты превращаются в нечто похожее на документы, составленные по разным учебникам. В итоге получается хаос — ты не можешь понять, кто, зачем и почему добавил тот или иной шаг. Та же проблема возникала с поиском тестовых данных. Никакой ясности, по каким критериям вообще выбираются данные для теста. 

И вот у нас возникает целая куча проблем — разный стиль шагов в Allure-отчётах, сложность в анализе результатов тестов, проверки в коде теста мешали его читаемости. И мы подумали: «Сколько можно?»  И решили  разработать инструмент, который возьмёт на себя генерацию SQL, все не стандартные проверки и красивые отчеты оставив нам только логику тестов. 

Давайте посмотрим как на примере CheckMateDB можно генерировать SQL-запросы, выполнять кастомные проверки и поддерживать единый стиль тестов и отчётов.

Генерация SQL-запросов в CheckMateDB 

Архитектура CheckMateDB выглядит примерно так: 

Она позволяет гибко работать с SQL-запросами, и делает всю работу за нас, без лишних усилий и головной боли. С этим нам помогает один из наших главных героев истории — QueryBuilder! Он формирует запросы на основе переданных параметров как архитектор, который проектирует идеальные здания и отделяет логику запроса от конкретных данных. 

QueryBuilder создает запросы, которые можно потом красиво и правильно использовать. Плюс он поддерживает математические и логические операции и умеет работать с функциями БД. Вот код, сгенерированный этим инструментом:

SELECT 
     sprav_inkass.*
 FROM 
     ibs.z#sprav_inkass sprav_inkass
 JOIN 
     ibs.z#product product ON sprav_inkass.id = product.id
 LEFT JOIN 
     ibs.z#depart depart ON product.c_depart = depart.id
 WHERE 
     depart.c_code LIKE '035%'

Он помогает нам динамически формировать SELECT, WHERE, JOIN, и всё, что угодно. С помощью QueryBuilder можно собирать SQL-запросы, как из конструктора Lego, где каждая деталь идеально подходит. Причем QueryBuilder умеет динамически настраивать как простые SELECT-запросы, но и сложные операции, такие как JOIN, WHERE, ORDER BY.

queryBuilder
        .select(ColumnCollection.ALL)
        .from(tableSpravInkass)
        .join(join(tableProduct, equal(tableSpravInkass.ID, tableProduct.ID)))
        .join(leftJoin(tableDepart, equal(tableProduct.C_DEPART, tableDepart.ID)))
        .where(like(tableDepart.C_CODE, depart))
        .setParameter(ParameterUtil.like(tableDepart.C_CODE, depart));

Если QueryBuilder — это архитектор SQL-запросов, то у нас есть еще один мастер на все руки — CriteriaBasic. Это тот самый парень, который предоставляет его наследникам полный функционал QueryBuilder, делает код чище, структурированнее и избавляет нас от ручного написания SQL. Теперь не нужно вручную составлять SQL-запросы! Вместо этого мы описываем критерии поиска в виде Java-кода. Всё аккуратно, элегантно и в едином стиле - это делает поддержку и расширение кода максимально простыми.

Все просто! Мы объявляем класс с критериями поиска того или иного объекта в базе данных, а QueryBuilder сам формирует SQL-запрос. Это не только упрощает код, но и позволяет легко его дополнять, например, добавляя новые параметры выборки.

Если у вас есть, скажем, сущность Deposit, то для нее можно создать CriteriaDeposit, который будет отвечать за поиск депозитов и наследоваться от CriteriaBasic. Реализуем конструктор в котором инициализируется QueryBuilder и у нас по умолчанию есть обычный SELECT.

public class CriteriaDeposit extends CriteriaBasic<DtoDeposit> {
 
    public TableDeposit tableDeposit;
    private TableProduct tableProduct;
 
    public CriteriaDeposit() {
 
        super(DtoDeposit.class);
 
        setByDefault();
    }
 
    /**
      Установить соединения по умолчанию.     
    */
    private void setByDefault() {
 
        tableDeposit = new TableDeposit(aliasSuffix);
 
        queryBuilder = new QueryBuilder()
                .select(ColumnCollection.ALL)
                .from(tableDeposit);
    }
 }

Далее реализуем Table с колонками из базы данных, с которыми мы будем работать - TableDeposit. 

@Table(name = "Deposit")
 public class TableDeposit extends CommonColumn implements CommonTable {
 
    @Getter
    private final String aliasByDefault = getTableNameToLowerCase();
 
    public final Column ID = ColumnCollection.getId();
    public final Column C_KL_1_2 = Column.set("C_KL#1#2", "Расчетный счет");
 
    public TableDeposit(@Nullable String aliasSuffix) {
        alias = aliasByDefault;
        if (null != aliasSuffix) {
            alias += "_" + aliasSuffix;
        }
        initializeColumn(this);
    }
 }

Далее зададим критерий поиска, который элегантно встроится в основной SELECT-запрос, словно последний кусочек пазла. Это стало возможным благодаря реализованному паттерну флюентного интерфейса в каждом Criteria-билдере — теперь запросы не только работают, но и читаются, как хорошо написанный диалог с базой данных. 

public CriteriaDeposit withDepartLike(String depart) {
 
    TableDepart tableDepart = new TableDepart(aliasSuffix);
    tableProduct = new TableProduct("ForDep");
 
    queryBuilder
            .join(join(tableProduct, equal(tableDeposit.ID, tableProduct.ID)))
            .join(leftJoin(tableDepart, equal(tableProduct.C_DEPART, tableDepart.ID)))
            .where(like(tableDepart.C_CODE, depart))
            .setParameter(ParameterUtil.like(tableDepart.C_CODE, depart));
 
    return this;
 }

Осталось только создать запрос по нужным нам критериям из предусловия тест-кейса.

deposit = new CriteriaDeposit()
        .withStatusEquals(ComStatusPrd.$WORK)
        .withDepartLike("035%")
        .joinAccount(new CriteriaAccount()
                .withBalanceAccountEqualsTo(BalanceAccount.$40702)
                .withStatusEquals(ComStatusPrd.$WORK))
        .getRandomObject(7);

Готово! Запускаем автотест получаем сгенерированный SQL-запрос, который отправляется в базу данных.

Но как мы отправим этот запрос в БД? Тут в игру вступает DaoCommon, методы которого используются в CriteriaBasic. Он берет на себя всю работу по выполнению SQL-запроса и обработке результата. Он использует сессионную фабрику Hibernate. Когда мы вызываем метод поиска в CriteriaBasic, тот передает сформированный SQL в DaoCommon, а он в свою очередь отправляет его в DaoCommonBasic, который уже отвечает за его выполнение.

/**
  Получает объект заданного типа без проверки на null.
 
  @return объект типа {@link T}
 */
 public T getObject() {
 
   LogHelper.logSearchEntityParameters(getParametersAsString());
 
   T result = DaoCommon.getInstance()
           .getInstanceEntityByParameters(this, dtoClass);
 
   LogHelper.logResult(result);
 
   return result;
 }

Внутри DaoCommonBasic мы инжектим SessionFactory, при помощи внутреннего механизма Service Locator, чтобы получить Session и запустить запрос. Это позволяет нам управлять транзакциями и кэшированием на уровне Hibernate.

/**
  Возвращает экземпляр сущности по параметрам
 
  @param parameters параметры
  @param clazz      класс {@link Class}
  @return {@link T} или NULL
 */
 @Nullable
 public <T> T getInstanceEntityByParameters(CommonBuilderSql parameters, Class<T> clazz) {
   int maxResults = 1;
   List<T> result = daoCommonBasic.getEntitiesByExpression(parameters.buildSql(), clazz, maxResults);
   return result.isEmpty() ? null : result.get(--maxResults);
 }
/**
  Выполняет SQL-запрос и возвращает список сущностей.
 
  @param sqlExpression SQL-выражение для выполнения.
  @param clazz         Класс сущности.
  @param maxResults    Максимальное количество записей в результате. Если {@code -1}, возвращаются все записи.
  @param <T>           Тип сущности.
  @return Список найденных сущностей.
  @throws DatabaseException Если произошла ошибка при выполнении SQL-запроса.
 */
 <T> List<T> getEntitiesByExpression(String sqlExpression, Class<T> clazz, int maxResults) {
   try (Session session = sessionFactory.openSession()) {
       Query<T> query = createSQLQuery(session, sqlExpression, clazz, maxResults);
       List<T> entities = query.getResultList();
       LogHelper.logQueryResult(entities, clazz);
       return entities;
   } catch (Exception e) {
       throw handleException(e, "Ошибка выполнения SQL-выражения: " + sqlExpression);
   }
 }
@Slf4j
 final class DaoCommonBasic {
 
   /**
      Фабрика сессий Hibernate для управления соединениями с базой данных.
   */
   private final SessionFactory sessionFactory;
   /**
    Конструктор. Инициализирует фабрику сессий через {@link HibernateManager}.
   */
   public DaoCommonBasic() {
       this.sessionFactory = BeanFactory
           .getInstance()
           .getBean(HibernateManager.class)
           .getSessionFactory();
   }
 
   ......
 
 }

У нас настроено логирование через Hibernate SQL Logger и внутренний LogHelper, так что мы можем видеть в логах все исполняемые SQL-запросы вместе с параметрами. Получается, что мы можем просто вызывать метод, передать параметры, а дальше система сама все делает. Заодно мы решили проблему с информативностью Allure-отчета, ручной тестировщик при открытии отчета поймет, по каким параметрам мы искали данные для прохождения автотеста и что получили в итоге.

Этот подход позволил нам не только унифицировать генерацию SQL-запросов, но и улучшить поддержку тестов. Теперь изменения в системе требуют правок только в одном месте — в критериях, а не во всех тестах. Также мы добились единого стиля кода для поиска данных. И заказчик теперь видит в Allure-отчётах параметры по которым мы ищем тестовые данные, которые выглядят как обычные ручные тест-кейсы.

Кастомные проверки данных в CheckMateDB

При всем удобстве библиотеки AssertJ, её нам было недостаточно.

Объекты которые нам необходимо проверить, как правило, сложные и многоуровневые. При этому в пределах секции (одной тематики) проверки могут быть однотипны. Например, проверка статуса документа. Поэтому мы решили создавать кастомные классы проверок под каждую проверяемую сущность. За счет этого впоследствии ускоряется время разработки тестов.

Так как в нашем проекте делается упор на описание шагов теста через Allure, нам так гораздо проще поддерживать единый стиль этих самых «шагов», ведь все под рукой в пределах класса, а не разбросано по сотням тестов, где каждый пишет как хочет.

Помимо этого, многие данные для проверки бывает не так-то и просто получить из проверяемого нами ПО. Условно для получения некоторых данных нужно сделать «два притопа, три прихлопа». Например, у документов есть «статус», и зачастую по ходу теста нам приходится ждать по таймеру изменение этого статуса пока тестируемое ПО обработает этот документ.

Вот как могут выглядеть требования в тест-кейсе

  • В представлении "Операции с ценностями (Операции пользователя за опердень)" появилась новая строка

  • Дата создания = Дата текущего ОД на тестовой схеме

  • Наименование операции по учету ценностей = РСХБ. Продажа монет клиенту по договору комиссии

  • Состояние = Исполнение

Без кастомных классов проверок это могло бы выглядеть так: 

// Проверить: объект существует
 Assertions.assertThat(richesBank)
         .isNotNull();
 
 // Проверить: Дата создания = Дата текущего ОД на тестовой схеме
 Assertions.assertThat(richesBank.getDateOp())
         .isEqualTo(BasicPage.operationalDay);
 
 // Проверить: Наименование операции по учету ценностей = РСХБ.
 Продажа монет клиенту по договору комиссии
 Assertions.assertThat(richesBank.getLinkTypeOpRich().getCode())
         .isEqualTo(CSD.getCode());
 
 // Проверить: Состояние = Исполнение
 LocalDateTime timer = LocalDateTime.now().plusSeconds(120);
 Dto_RichesBank richesBankUpdated = null;
 while (LocalDateTime.now().isBefore(timer)) {
     richesBankUpdated = DaoCommon.getInstance().getInstanceEntityById(richesBank.getId(),
 Dto_RichesBank.class);
     if (Objects.equals(richesBankUpdated.getState(), DocStates.RICHES_BANK_EXEC.code)) {
         break;
     } else {
         GeneralUtils.pause(5);
     }
 }
 
 assert richesBankUpdated != null;
 if (!Objects.equals(richesBankUpdated.getState(), DocStates.RICHES_BANK_EXEC.code)) {
     throw new AssertionError(String.format(
             "Unexpected value: (actual=%s, expected=%s)",
             richesBankUpdated.getState(),
             DocStates.RICHES_BANK_EXEC.code
     ));
 }

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

AssertionsUtil.assertThat(richesBank)
         .objectExist()
         .hasDateBeginEqual(BasicPage.operationalDay)
         .hasTypeOpRichEqual(CSD)
         .hasStateEqual(DocStates.RICHES_BANK_EXEC);

А так это выглядит в отчете Allure:

Реализация кастомных проверок

Изучив документацию по библиотеке AssertJ, мы предприняли попытку создать свои кастомные проверки. Все довольно просто: наследуемся от абстрактного класса AbstractAssert и дело в шляпе… Ну или почти. Для удобства мы сделали свои родительские классы BasicAssert и BasicListAssert с набором базовых проверок на основе AbstractAssert, с добавлением шагов Allure.

Чтобы всем на проекте было понятно, где искать нужный класс проверок для своего объекта, мы добавили единую точку входа для наших кастомных проверок – класс AssertionsUtil.

public class AssertionsUtil {
 
   public static MainDocumAsserts assertThat(DtoMainDocum actual) {
       return new MainDocumAsserts(actual);
   }
 
   public static R2DepositAsserts assertThat(DtoR2Deposit actual) {
       return new R2DepositAsserts(actual);
   }
   
   // И так далее...
 }

Рассмотрим процесс создания кастомного класса проверки. Для примера возьмем класс проверки для сущности “Дополнительное соглашение по выплате процентов”. Создадим сам класс, унаследуемся от BasicAssert. В отличие от AbstractAssert, нам в конструктор понадобится передать третий параметр - описание проверяемого объекта. В нашем случае мы передадим туда название - “Дополнительное соглашение по выплате процентов”. Оно будет попадать в шаги Allure-отчета и сообщения об ошибке, повышая информативность сообщений.

/**
  Класс для проверки сущности "Дополнительное соглашение по выплате процентов"
 
  @see Dto_AgreementPrc
 */
 public class AgreementPrcAsserts extends BasicAssert<AgreementPrcAsserts, Dto_AgreementPrc> {
 
     public AgreementPrcAsserts(@Nullable Dto_AgreementPrc actual) {
 
         super(
                 actual,
                 AgreementPrcAsserts.class,
                 ClassId.AGREEMENT_PRC.getPreparedName()
         );
     }
 
     public static AgreementPrcAsserts assertThat(Dto_AgreementPrc actual) {
 
         return new AgreementPrcAsserts(actual);
     }
 }

Далее напишем метод проверки. Их мы аннотируем шагами Step(). Также мы прописываем свое кастомное сообщение об ошибке. Каждую проверку следует делать NullSafe, поэтому первым делом проверим, что наш объект не NULL стандартным методом из AssertJ - isNotNull().    

Далее проведем непосредственно нашу кастомную проверку. В данном примере мы проверяем равен ли вид дополнительного соглашения ожидаемому. Здесь мы используем assertHelper - вспомогательный инструмент для проверки простых типов данных. Используем мы его по той же причине - его методы сопровождены шагами Allure, их так проще поддерживать и придерживаться единого стиля.

И наконец делаем return this, чтобы мы могли проводить над объектом множество проверок через так горячо нами любимый method chaining.

@Step("[{this.info.description}] Проверить: Вид ДС = {vidDopSogl.description}")
public AgreementPrcAsserts hasVidEquals(VidDopSogl vidDopSogl) {
 
    isNotNull();
 
    assertHelper.ofStringItem(
            "Вид ДС",
            AssertionType.EQUALS,
            actual.getLinkPrcAgrType().getName(),
            vidDopSogl.getDescription()
    );
 
    return this;
}

Но у нас есть сложные объекты: то есть там не только простые поля, а есть и связи 1-1, 1-ко-многим. Цепочки проверок выглядят вроде бы красиво. А тут как быть? Придется эту красоту разрывать чтобы передать дочерний объект? Проблема.

Мы сделали такой метод, чтобы ты мог проверять одни объекты, когда проверяешь другие объекты. Поначалу мы попробовали простой хардкод при создании методов «погружения/подъема», просто вручную прописывали откуда куда можно перейти и откуда куда можно вернуться. 

public class R2LoanAssert extends BasicAssert<R2LoanAssert, Dto_R2Loan> {
 
     public R2TranchesAssert goToR2TranchesAssert() {
         return new R2TranchesAssert(null, actual);
     }
 
     public R2AccAssert goToR2AccAssertAssert() {
         return new R2AccAssert(null, actual);
     }
 
     public R2AddAgrAssert goToR2AddAgrAssert(@Nullable Dto_R2AddAgr additionalAgreement) {
         return new R2AddAgrAssert(additionalAgreement, actual);
     }
 
     public R2PlanOperAssert goToR2PlanOperAssert() {
         return new R2PlanOperAssert(null, actual); 
     }
 
     public R2DepositAssert goToR2DepositAssert() {
         return new R2DepositAssert(null, actual);
     }
 
     public R2CodebtorInfoAssert goToR2CodebtorInfoAssert() {
         return new R2CodebtorInfoAssert(null, actual);
     }
 }
public class R2AddAgrAssert 
          extends AbstractAssert<R2AddAgrAssert, Dto_R2AddAgr> {
     
     public R2LoanAsserts returnToR2LoanAsserts() {
         return (Dto_R2Loan) parentAssert;
     }
 
     public R2DepositAsserts returnToR2DepositAsserts() {
         return (R2DepositAsserts) parentAssert;
     }
 
     public MainDocumAsserts returnToMainDocumAsserts() {
         return (MainDocumAsserts) parentAssert;
     }
 }

Но при погружении разработчик будет вынужден держать в голове откуда он пришел, чтобы вызвать нужный метод подъема, либо мучаться каждый раз, когда программа будет падать с ошибкой CastException. Тем не менее, идея ходить по связанным объектам в рамках одной цепочки проверок нам понравилась, и мы решили развивать её дальше.

Затем перешли к созданию оберток классов с использованием Generic’ов, которые позволяли подниматься наверх через метод and(). Но с этим вариантом тоже были проблемы. Приходилось создавать обертки на Assert классы, где в поле мы держали родительский класс проверок. 

public class NestedDocComJourAssert<PARENT extends AbstractAssert<?,?>>
         extends AbstractAssert<NestedDocComJourAssert<PARENT>, Dto_DocComJour> {
 
     private final PARENT parent;
 
     public NestedDocComJourAssert(Dto_DocComJour actual, PARENT parent) {
 
         super(actual, NestedDocComJourAssert.class);
         this.parent = parent;
     }
 
     public NestedDocComJourAssert<PARENT> withSumEqual(Double expectedSum) {
 
         DocComJourAsserts.assertThat(actual).hasSumEqual(expectedSum);
         return this;
     }
     
     // и остальные обертки на методы проверок...
 
     public PARENT and() {
 
         return parent;
     }
 }

И плюс еще был такой вот момент. У нас в идее нет правил на форматирование таких конструкций и приходилось визуализировать вложенность через отступы самостоятельно.

И вот вам ситуация. Написали вы сложную цепочку проверок, навели красоту, запушили тест и пошли дальше радоваться жизни. Но тут приходит другой разработчик, добавляет в ваш класс свой тест, и форматирует код: все ваши красивые отступы пропадают.

В итоге мы остановились на использовании Consumer’ов в параметрах метода погружения. Для «погружения» достаточно создать один метод в верхнем классе. 

AssertionsUtil.assertThat(agreementPrc)
         .objectExist()
         .hasVidEquals(VidDopSogl.$НЕСНИЖ_ОСТ)
         .wherePrcRatesList(prcRatesListAsserts -> prcRatesListAsserts
                 .hasSize(3)
         );

Внутри метод «погружения» к списку элементов будет выглядеть так:

public AgreementPrcAsserts wherePrcRatesList(Consumer<PrcRatesListAsserts> assertion) {
 
     isNotNull();
 
     List<Dto_PrcRates> prcRatesList = actual.getListPrcRates();
 
     assertion.accept(new PrcRatesListAsserts(prcRatesList));
 
     return this;
 }

Аналогично можно организовывать и переходы 1-1, там вместо листа будет передаваться конкретный объект.

Помимо проверок списка нам надо было как-то осуществлять переход из списка к конкретному объекту. Для этого в нашем стандартном классе BasicListAssert есть метод с поиском по предикатам: из списка отбирается первый подходящий объект и происходит переход к проверкам этого объекта. 

AssertionsUtil.assertThat(rkoFor308111.update())
         .hasAccountStatusEqual(ComStatusPrd.$WORK)
         .whereDocComJour(docComJourListAsserts -> docComJourListAsserts
                 .whereContains(
                         PredicateDocComJur.with()
                                 .operationEqual(BusinessOperation.КОМИСС_1_1_1_РСХБ)
                                 .dateCalcEqual(DateUtil.getOperDayByUser(userCreatorFor308111)),
                         docComJourAsserts -> docComJourAsserts
                                 .objectExist()
                                 .hasSumIsNotBlank()
                 )
         );

Для того чтобы предикаты также могли попадать в алюр отчет, мы добавили класс BasicPredicate. В общем-то это все тот же предикат, но к нему можно добавить текстовое описание, которое потом попадет в отчет. 

public class PredicateDocComJur extends BasicPredicate<Dto_DocComJour> {
 
     public PredicateDocComJur byDateCalcEqual(LocalDate dateCalc) {
 
         predicates.add(oper -> oper.getDateCalc().equals(dateCalc));
         parameters.add(String.format("Дата расчёта=%s", dateCalc.format(DtPattern.DATE_LONG)));
         return this;
     }
 
     public PredicateDocComJur bornDocIsNull() {
 
         predicates.add(oper -> Objects.isNull(oper.getLinkBornDocument()));
         parameters.add("Отсутствует порождённый документ");
         return this;
     }
 
     public PredicateDocComJur bySumEqual(Double sum) {
 
         predicates.add(oper -> oper.getSumma().equals(sum));
         parameters.add(String.format("Сумма=%s", sum));
         return this;
     }
 }

Плагин для Boilerplate Кода

И еще один момент. При нашем подходе с генерацией SQL-запросов через QueryBuilder, ему требуются вспомогательные классы Table. А они - бойлерплейт код, который писать совсем неинтересно и утомительно. И чтобы упростить и ускорить написание кода для критериев, мы сделали плагин, генерирующий классы Table.

Пользователь вводит наименование таблицы, с которой он хочет работать. Это может быть любая таблица, доступная в базе данных.

После ввода наименования таблицы, пользователь получает возможность выбрать необходимые поля из этой таблицы. Это позволяет гибко настраивать, какие именно данные будут включены в сгенерированные классы.

После выбора полей пользователь нажимает кнопку "ОК". В этот момент плагин формирует SQL-запрос, который отправляется в базу данных для извлечения информации о выбранной таблице и полях.

При этом мы можем держать у себя на проекте свои шаблоны в папке .idea/fileTamplates где можем, например, скорректировать свою джавадоку или подход к названию классов.

Главное, чтобы никто не застрял в рутинных мелочах. Мы должны дать людям возможность генерировать свой код, а не тратить время на эти бесконечные таблицы.

Итак, что нам дал такой подход?

Мы получили кучу свободного времени, меньше головной боли и, возможно, даже шанс дожить до дедлайна без кофеинового передоза. Благодаря method chaining тесты читаются, как обычный текст. Теперь даже джуниор без детектора лжи сможет сказать, что тест делает!

Поддержка тестов теперь проще — поменял логику проверки в одном месте, а поменялась во всех тестах. И никакого мусора в коде! Только чистая, минималистичная красота. 

И главное — тесты работают быстрее. SQL-запросы проходят через наш механизм, без гаданий, почему они тормозят. 

Значит, у нас теперь идеальное автоматизированное тестирование? Будем ждать ответа в комментариях.

Комментарии (0)