Привет, Хабр! Меня зовут Михаил Палыга, я инженер в Блоке обеспечения и контроля качества выпуска изменений ПО в РСХБ‑Интех. Разработчик я начинающий, в компании работаю около года, плюс 6 лет обучения в профильном вузе. В данной статье хочу рассказать, как мы в нашем проекте проводим проверки данных в наших тестах.

Мы занимаемся разработкой автотестов для АБС ЦФТ‑Банк — автоматизированной банковской системы (АБС), разработанной ООО «Центр финансовых технологий». Это ядро IT‑экосистемы банка. Тесты состоят из трех этапов: формирование условий, воздействие и проверка результата. Кстати, о том как и каким инструментом мы подбираем тестовые данные, мы писали ранее в этой статье, а об особенностях перехода с Oracle на PostgreSQL мы писали тут.

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

Далее в статье я опишу, как со временем менялся наш подход к проведению проверок данных и как менялись сами классы проверок. А чтобы было чуть проще и интересней займемся тестированием чего‑нибудь из вселенной Звездных Войн. Например, протестируем имперский бронированный транспортный вездеход AT‑AT.

Схемы БД и код методов будет представлен в упрощенном виде. Представим, что в БД информация о нашем вездеходе хранится в одной таблице, без каких-либо связей:

Давным-давно, в далеком-далеком проекте…

«Это были тяжелые времена, мы проверяли данные как могли».

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

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

А там, где и было возможно код переиспользовать, начали появляться длинные цепочки наследований:

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

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

Войны клонов

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

Например, по тесту нам надо проверить, что наш АТ-АТ соответствует следующим параметрам:

  • Количество экипажа: 3

  • Класс: Шагоход

  • Грузовместимость: 4т

Так бы в теле теста выглядел наш метод проверки объекта:

@Test
void at_at_test() {

   ArmoredTransportChecker.checkArmoredTransport(
         objectUnderTest,
         3,
         4d,
         "Шагоход"
   );
}

Но допустим у нас появляется еще один тест на AT-AT, где надо проверить еще и место, где был произведен шагоход. Итого нам нужно проверить следующее:

  • Количество экипажа: 3

  • Класс: Шагоход

  • Грузовместимость: 4т

  • Производитель: Верфи Куата 

Тут ярко видна проблема создания неатомарных проверок: либо сделаем копию старого метода и добавим туда еще один параметр, либо будем разбивать старый метод на отдельные проверки и добавлять еще и свою.

@Test
void at_at_test2() {

   ArmoredTransportChecker.checkArmoredTransport(
         objectUnderTest,
         3,
         4d,
         "Шагоход",
         "Верфи Куата"
   );
}

За время работы с классами типа Checker были выявлены следующие проблемы:

  • Неконсистентность сигнатуры методов. Часть методов не возвращала ничего, часть могла что-нибудь и вернуть. Где-то первым параметром передавался объект, а где-то ожидаемое значение. 

  • Неатомарность проверок. Некоторые методы могли производить сразу ряд проверок. Из-за этого могли появляться методы-клоны с еще одной дополнительной проверкой в теле метода. 

  • Разный подход к проверкам в теле методов. Могли использоваться разные библиотеки для проверок данных, а также ключевое слово assert. 

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

Новая надежда

Изучив документацию по библиотеке AssertJ, мы предприняли попытку создать свои кастомные проверки. Оказалось, все довольно просто: наследуемся от абстрактного класса AbstractAssert и дело в шляпе (ну почти). 

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

public class AssertionsUtil {

   public static ArmoredTransportAsserts assertThat(DtoArmoredTransport actual) {

      return new ArmoredTransportAsserts(actual);
   }

   public static DtoManufacturerAsserts assertThat(DtoManufacturer actual) {

      return new DtoManufacturerAsserts(actual);
   }
  
   // И так далее...
}

К примеру, метод проверки размера экипажа может выглядеть так:

public ArmoredTransportAsserts hasCrewSize(Integer expectedSize) {

   isNotNull();
  
   if (!Objects.equals(actual.getCrewSize(), expectedSize)) {
      failWithMessage(
            "Ошибка! Количество экипажа не соответствует ожидаемому. " +
                  "Ожидалось: %s, фактически: %s",
            expectedSize,
            actual.getCrewSize()
      );
   }

   return this;
}

А так будет выглядеть код проверки АТ-АТ в теле автотеста:

@Test
void at_at_test3() {

   AssertionsUtil.assertThat(objectUnderTest)
         .hasCrewSize(3)
         .hasCargoCapacity(4d)
         .hasClassName("Шагоход")
         .hasManufacturerName("Верфи Куата");
}

Пробуждение силы хардкода

В процессе использования новых кастомных проверок встал вопрос о том, как проверять связанные классы. Нужен был способ как, проверяя объект А, перейти к проверкам связанного с ним объекта В назовем это “погружением”. При этом должна была сохраниться возможность вернуться к проверкам объекта А назовем это “подъемом наверх”.

Давайте немного усложним нашу схему. Вынесем производителя и класс техники в отдельные таблицы:

Первым делом в ход пошел старый добрый хардкод.

Так выглядел метод погружения:

public ManufacturerAsserts whereManufacturer() {

   isNotNull();
   return new ManufacturerAsserts(actual.getLinkManufacturer(), this);
}

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

public ArmoredTransportAsserts returnToVehicleClassAsserts() {

   return (ArmoredTransportAsserts) parentAssert;
}

Однако, некоторый объект С мог быть связанным как с объектом А, так и с объектом В, следовательно, он должен иметь возможность подъема наверх к обоим классам. Например, производитель мог выпускать не только наземную технику, но и космические корабли. Соответственно, класс мог содержать сразу несколько методов “подъема наверх”.

Так стала выглядеть проверка в теле автотеста:

@Test
void at_at_test4() {

   AssertionsUtil.assertThat(objectUnderTest)
         .hasCrewSize(3)
         .whereVehicleClass()
         .hasName("Шагоход")
         .returnToArmoredTransportAsserts()
         .whereManufacturer()
         .hasName("Верфи Куата")
         .returnToArmoredTransportAsserts()
         .hasCargoCapacity(4d);
}

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

Скрытая угроза классов-оберток

Далее для реализации методов “подъема” мы опробовали Generic-методы. Уже стало лучше, не надо было держать в голове откуда ты по цепочке методов пришел в “нижнюю” проверку и как потом обратно возвращаться наверх. Теперь же появился универсальный метод and(), который возвращал нас точно туда, куда нам надо.

Но у такого подхода был и минус. Каждый класс и все его методы приходилось оборачивать в класс-обертку. Выглядело это так:

public class ManufacturerNestedAsserts<PARENT>
      extends AbstractAssert<ManufacturerNestedAsserts<PARENT>, DtoManufacturer> {

   private final PARENT parentAssert;

   public ManufacturerNestedAsserts(DtoManufacturer actual, PARENT parentAssert) {
      super(actual, ManufacturerNestedAsserts.class);
      this.parentAssert = parentAssert;
   }

   public ManufacturerNestedAsserts<PARENT> withName(String expectedName) {

      ManufacturerAsserts.assertThat(actual)
            .hasName(expectedName);
      return this;
   }

   public PARENT and() {
      return parentAssert;
   }
}

Проверка нашего шагохода стала выглядеть так:

@Test
void at_at_test5() {

   AssertionsUtil.assertThat(objectUnderTest)
         .hasCrewSize(3)
         .whereVehicleClass()
         .withName("Шагоход")
         .and()
         .whereManufacturer()
         .withName("Верфи Куата")
         .and()
         .hasCargoCapacity(4d);
}

Писать обертки на каждый класс, и уж тем более на каждый метод было не очень весело, так что мы решили поискать еще варианты.

Consumer’ы наносят ответный удар. 

На данный момент мы пришли к варианту с использованием Consumer’ов в параметрах метода погружения. Это позволило нам избавиться от классов-оберток и в целом от необходимости держать двустороннюю связь верхнего и нижнего класса.

Теперь для “погружения” достаточно было создать один метод в верхнем классе:

public ArmoredTransportAsserts whereManufacturer(Consumer<ManufacturerAsserts> assertions) {

   isNotNull();

   assertions.accept(
         new ManufacturerAsserts(actual.getLinkManufacturer())
   );

   return this;
}

Давайте еще разок доработаем нашу схему: вынесем экипаж в отдельную сущность и сделаем связь “один ко многим”.

Большой проблемы добавить проверки списка не было. Для удобства мы сделали свой родительский класс BasicListAssert с набором своих базовых проверок на основе AbstractAssert. 

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

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

public class VehiclePersonnelPredicate {

   public static Predicate<DtoVehiclePersonnel> hasType(String name) {
      return p -> p.getType().equals(name);
   }
}

В итоге проверка нашего шагохода стала выглядеть так:

@Test
void at_at_test6() {

   AssertionsUtil.assertThat(objectUnderTest)
         .whereVehiclePersonnel(vehiclePersonnelList -> vehiclePersonnelList
               .hasSize(3)
               .whereContains(
                     VehiclePersonnelPredicate.hasType("Командир"),
                     vehiclePersonnelAssert -> vehiclePersonnelAssert
                           .hasName("Максимилиан Вирс")
               )
         )
         .whereVehicleClass(vehicleClass -> vehicleClass
               .hasName("Шагоход")
         )
         .whereManufacturer(manufacturer -> manufacturer
               .hasName("Верфи Куата")
         )
         .hasCargoCapacity(4d);
}

Заключение

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

Данный инструмент будет удобен для использования не только во вселенной звездных войн, но и в любой другой предметной области. 

В одной из следующих статей мы опишем процесс создания компонентов поиска и проверки данных для теста на основе нашего нового инструмента CheckMateDB. 

Следите за новостями!

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


  1. TimReset
    20.11.2024 07:47

    Поставил плюс, но со статьёй не очень согласен. Так как видел такой подход как у вас, и видел более простой - основанный на методе assertEquals. Мне он показался более универсальным - чтобы выполнить все проверки, достаточно только одного метода. Можно его несколькими расширить, типа assertNull/notNull/true/false, но всё равно внутри это будет тот же assertEquals. В вашем подходе для каждого атрибута нужно писать свою обёртку. А когда таких классов на проекте десятки и они увеличиваются - по моему это очень накладно. Так что не могли бы вы написать, почему для вас этот подход лучше обычных assertEquals? Интересно узнать другое мнение. :-)