В этой статье речь пойдет о создании тестов в java приложениях, в первую очередь unit-тестов, а точнее, будем говорить о генерации тестовых данных. Вообще, я считаю проблему генерации тестовых данных в тестировании центровой. Во-первых, необходимо осознать какие же данные нужны для теста, во-вторых, их необходимо подготовить и сгенерировать. На проектах уровня hello world или при очень хорошей декомпозии проблема невелика, но на больших проектах с большими DTO, это мало того что сложно, так еще и занудно. В какой-то момент количество кода теста может многократно превышать количество тестируемого кода.

Предпосылки

На одном из проектов, где я работал, часть сущностей для тестов (DTO и пр.) генерировалась автоматически при помощи PodamFactory (https://mtedone.github.io/podam/) и, в принципе, все было хорошо. Но, во-первых, сущности содержали адовое количетво полей. Во-вторых, хотелось бы, чтоб данные выглядели правдоподобно, например, если поле представляет собой название города, то там было какое-то название города, а не случайный набор символов. Во-вторых, на данные могут накладываться какие то ограничения, простым примером может служить ограничение на длинну строки поля. В-третьих, данные формально могут быть преставлены строкой, но при этом могут содержать в себе некоторую структуру, скажем, ИНН клиента в первых двух символах содержит код субьекта РФ и так далее.

Пожелания

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

  1. Генерирует правдоподобные данные, не режущие глаз, например, если поле является названием города, то там какое либо знакомое название;

  2. Генерирует данные по простейшим ограничениям, как то длина строк или максимальные/минимальные значения численных данных или, скажем, берет рандомно какое-то значение из перечисления (java.lang.Enum);

  3. Генерирует данные в соответствии с некотрой внутренней структой, к примеру, ИНН клиента или, скажем, номер банковского счета, в котором часть символов обозначают код валюты;

  4. Если мы имеем сложный DTO, то иметь возможность большую часть полей сгенерировать по определенным общим правилам, а часть полей в данном месте теста описать более подробно, к примеру, 50 полей DTO объекта сгенерированы по общим условиям, а номер телефона задать конкретный, желаемый именно в данном месте теста;

  5. Хотелось бы иметь максимально простой способ описания наших требований к тестовым данным, в идеале какой-то декларативный способ;

  6. Хотелось бы из коробки иметь возможность генерировать экземпляры самых распространенных классов, во всяком случае те, что идут с JRE/JDK.

Вперед к светлому будущему...

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

Итак, прошу любить и жаловать проект genthz.org (genthz on github).

Как это работает

В java создание объектов можно разложить на два этапа: первый – это, собственно, создание экземпляра класса (выделение памяти и начальная инициализация) при помощи конструктора или статического метода-фабрики (под капотом, опять же конструктор) и второй – заполнение полей объекта, которые не были (а может и были) проинициализированы на первом этапе. Весь этот процесс очевидно рекурсивный, потому что каждое поле объекта это тоже объект, который надо создать и заполнить. Также, можно выделить отдельный класс объектов, для которых второй этап – заполнения, необходимо пропустить. К таким объектам можно отнести все примитивы java, а так же всякие java.lang.String, java.math.BigInteger и прочие, которые вы можете придумать сами.

Для каждого этапа (org.genthz.context.Stage) решено было сделать отдельный интерфейс, реализации которого обеспечили бы выполнение этого этапа. Для первой фазы CREATING (создание объекта) используются org.genthz.function.InstanceBuilder, для второй фазы FILLING (заполнение) используется org.genthz.function.Filler.

Очевидно, что у нас есть масса стандартных классов, которые идут с JRE/JDK и для них хотелось бы иметь instance builder’ы и filer’ы по умолчанию. И они есть (пока только для java 1.8), параметры для многих страндартных классов можно найти в org.genthz.Defaults. Система по умолчанию умеет генерировать примитивы, строки, коллекции, стримы и пр.
Вообще, в пакете верхнего уровня (org.genthz) вы найдете в основном интерфейсы, для них существует реализация по умолчанию в пакете (org.genthz.dasha).

Life samples

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

<dependency>
		<groupId>org.genthz</groupId>
		<artifactId>genthz-core</artifactId>
		<version>3.1.3</version> <!-- or latest version -->
</dependency>

Далее, получает объект класса ObjectFactory и генерируем объекты.

Пример генерации из коробки

Пример генерации строки:

ObjectFactory objectFactory = new DashaObjectFactory();
String value = objectFactory.get(String.class);

Работа с объектами и собственными алгоритмами создания (instanseBuilder) и заполнения (filler) объектов

Можно работать со сложными объектами:

public class Person {
    private Long id;
  
    private String name;
  
    private String lastName;
  
    private LocalDate birthDate;

    public Person() {
    }
}

Пример генерации объекта Person

ObjectFactory objectFactory = new DashaObjectFactory();
Person person = objectFactory.get(Person.class);

Что делать, если хочется поменять алгоритм создания (instanceBuilder) объекта? Это легко сделать:

Person value = new DashaDsl() {
        {
//          Укажем, что хотим задать правила для генерации объектов класса Person.
            strict(Person.class)
//          Вызовем метод simple, чтобы указать, что после создания объекта пропустить фазу заполнения его полей. 
                    .simple(ctx -> {
                        Person person = new Person();
                        person.setId(1L);
                        person.setName("Oliver");
                        person.setLastName("Brown");
                        person.setBirthday(LocalDate.now());

                        return person;
                    });
        }
    }
//  Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
    .def()
    .objectFactory()
    .get(Person.class);

Можно так же изменить алгоритм (filler) заполнения объекта:

Person value = new DashaDsl() {
        {
//          Укажем, что хотим задать правила для генерации объектов класса Person.
            strict(Person.class)
//          Укажем что хотим использовать свой алгоритм заполнения объекта (filler).
                .filler(ctx -> {
//                  Get instance created by default instance builder.
                    Person person = ctx.instance();
                    person.setId(10L);
                    person.setName("Oliver");
                    person.setLastName("Brown");
                    person.setBirthday(LocalDate.now());
                    }));
        }
     }
//  Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
    .def()
    .objectFactory()
    .get(Person.class);

Можно задать одновременно и свой instanceBuilder и filler:

Person value = new DashaDsl() {
        {
//          Укажем, что хотим задать правила для генерации объектов класса Person.
            strict(Person.class)
//              Укажем алгоритм для генерации объекта типа Person.
                .instanceBuilder(ctx -> new Person)
//              Укажем алгоритм заполнения полей объекта.
                .filler(ctx -> {
//                  Get instance created by default instance builder.
                    Person person = ctx.instance();
                    person.setName("Oliver");
                    person.setLastName("Brown");
                    person.setBirthday(new Date());
                    }));
        }
   }
// Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
   .def()
   .objectFactory()

Рекурсия

Что делать если в структуре объекта есть рекурсия?

public class Recursion {
    private Recursion recursion;
}

По идее, должно случиться что-то типа java.lang.StackOverflowError, однако, в DashaDsl устанавливается специальный filler, который прерывает цепочку генерации на глубине Defaults#defaultMaxGenerationDepth(). В принципе, этот параметр можно менять по мере надобности:

Recursion value = new DashaDsl()
//      Установить параметры генераторов по умолчанию.
        .defaults(new DashaDefaults(){
//          Изменить глубину рекурсии на 10.
            @Override
            public Function<Context, Long> defaultMaxGenerationDepth() {
                return ctx -> 10L;
            }
        })
// 	Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
        .def()
        .objectFactory()
        .get(Recursion.class);

Использование путей (path)

Иногда при генерации объекта необходимо задать кастомное правило только для одного из его полей, это можно сделать как при помощи instanceBuilder:

Person value = new DashaDsl() {
        {
//          Укажем, что хотим задать правила для генерации объектов класса Person.
            strict(Person.class)
//          Укажем, что надо сгенерировать только поле «name».
                .path("name")
//              Указываем, используя simple, что нужно создать объект, но не заполнять его поля.
                .simple(ctx -> "Alex");
        }
     }
//   Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
     .def()
     .objectFactory()
     .get(Person.class);

так и при помощи filler:

Person value = new DashaDsl() {
        {
  //       Укажем, что хотим задать правила для генерации объектов класса Person.
    	   strict(Person.class)
  //         Укажем, что надо сгенерировать только поле «name
             .path("name")
    //       Задаем алгоритм его заполнения.
             .filler(ctx -> ctx.set("Alex"));
        }
    }
// 	Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
    .def()
    .objectFactory()
    .get(Person.class);

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

Person value = new  DashaDsl() {
        {

//          Укажем, что надо сгенерировать только поле «name».
            path("name")
//              Тип поля должен быть String.
                .strict(String.class )
//              Зададим instanceBuiler и укажем, что после создания заполнять объект не нужно.
                .simple(ctx -> "Alex");
        }
    }
//   Метод  def() вызываем, что бы подключить генерацию объектов по умолчанию.
    .def()
    .objectFactory()
    .get(Person.class);

Generics

Как работать если в описании класса есть generics? Нужно просто указать их при вызове метода ObjectFactory.get(). Если в качестве generics используются обычные классы, то просто подставляем их:

Map<String, Person> map = objectFactory.get(
  Map.class,
  String.class,
  Person.class
);

А что делать, если у нас есть generics of generics of generics?! Нет никаких проблем, просто нужно передать объекты типа java.lang.reflect.ParameterizedType. Но это интерфейс и что бы его получить можно воспользоваться, к примеру, утилитным классом org.apache.commons.lang3.reflect.TypeUtils библиотеки commons-lang3 или любой другой по работе с reflection.

public class FuriousGenerics<A, B> {
	private A field0;

	private Map<A, List<B>> fiedl1;

	private Deque<Map<A, B>> field2;
}
FuriousGenerics<Map<Set<String>, Collection<Person>>, List<Integer>> furiousGenerics = objectFactory.get(
        FuriousGenerics.class,
        TypeUtils.parameterize(
                Map.class,
                TypeUtils.parameterize(Set.class, String.class),
                TypeUtils.parameterize(Collection.class, Person.class)
        ),
        TypeUtils.parameterize(
                List.class,
                Integer.class
        )
);

Размеры генерируемых коллекций

По умолчанию размер генерируемых коллекций возвращается методом Defaults.defaultCollectionSize(). Поэтому, если вы хотите изменить размеры всех коллекций сразу, то можете сделать это так:

ObjectFactory objectFactory = new DashaDsl() {
    {
 	defaults(new DashaDefaults(){
            	@Override
        	public int defaultCollectionSize() {
      //		Размер коллекций по умолчанию равен 50.
        	 	return 50;
    		}
      });
    }
}
.def()
.objectFactory();

Если для какого-то типа коллекций нужно указать свой размер, то это можно сделать так:

ObjectFactory objectFactory = new DashaDsl() {
    {
        unstrict(Deque.class)
                .filler(CollectionFillers.size(10));
    }
}
.def()
.objectFactory();

В примере выше мы использовали функцию unstrict для указания типа коллекции что бы казать, что размер задается для коллекций типа Deque, а также всех его потомков и параметризаций, т.е. Для ArrayDeque и, например, для Deque<String>.

Другими словами, если вы хотите задать размер только для Deque<String> ,то стоит поступить так:

ObjectFactory objectFactory = new DashaDsl() {
    {
        strict(Deque.class, String.class)
                .filler(CollectionFillers.size(10));
    }
}
.def()
.objectFactory();

Вместо заключения

Эта статья — небольшая презентация проекта. Более подробную информацию можно получить в описании проекта на сайте https://genthz.org, которое я скоро также обновлю. В других статьях я продолжу обсуждать тему генерации данных и развития этого проекта.

Я буду рад, если моя работа поможет кому-то упростить работу с тестами в своих проектах. И буду особенно благодарен, если кто-то найдя ошибки в работе генератора, сообщит мне о них. Кроме того, если у вас есть идеи относительно улучшения юзабилити генератора, пишите мне о них или заводите задачи на сайте genthz на github .

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


  1. dyadyaSerezha
    31.12.2023 07:39
    +2

    1) Названия проекта и пакета по умолчанию - неудачные, особенно dasha

    2) В начале заявлена цель - декларативность, а потом идёт сплошной код.

    3) Неужели для такой распространенной задачи нашёлся только одна библиотека?

    4) Для большинства сложных проектов используют слепки базы данных из прода, где в какой-то степени "замазаны" чувствительные данные. А иначе замучаешься генерить.


    1. mathter Автор
      31.12.2023 07:39

      1) Возможно, возможно стоит сделать ребрендинг :). Название dasha использовал в честь имени дочери, по праву создателя проекта.

      2) Наверное согласен, пожалуй даже скорректирую текст;

      3) Библиотеки как оказались есть, одну указали в одном из комментариев к статье https://github.com/instancio/instancio . Как ни странно, уже после начала работы над своим проектом я её не нашел, точнее в 2021 году ее еще не было. Нашел большое сходство идей, которые заложил в свою библиотеку я и авторы instancio. Но процесс уже пошел и решил продолжить работу - составить им конкуренцию. Некоторые вещи мне кажется я сделал более удачно, у меня есть Dsl у них Model. Мой Dsl это в сущности собранные вместе Model для использования это удобно;

      4) Эта тема очень обширная, для больших систем, интеграционных тестов и пр. действительно используются слепки БД с прода. Мы их тоже используем, на это все накладываются куча ограничений административных и ограничения безопасности и т.д и т.п. Я создавал библиотеку в которой в одном месте (`Dsl`) задает параметры для генерации объектов "на лету".


  1. ma1uta
    31.12.2023 07:39
    +1

    Под все указанные требования подходит библиотка: https://github.com/instancio/instancio

    Почему вы решили вместо неё писать свой вариант?


    1. mathter Автор
      31.12.2023 07:39

      Спасибо, я ждал этот вопрос :) Ответ простой, в 2021 году когда начал впервые работать над проектом genthz,я ее попросту не нашел. Прямо сейчас я посмотрел, история репозитория instancio началась в 03.03.2022 . Многие идеи у нас совпадают.

      Одну вещь я у них спер :) это использование getter'ов и setter'ов вместо строковых имен полей, при указании специфических генераторов для них (у меня это [FieldMatchers](https://github.com/mathter/genthz/blob/dev/3.1.x/genthz-core/src/main/java/org/genthz/FieldMatchers.java) у них Select#field(...)).

      Точно могу сказать, что документация у них пока что лучше :)

      Библиотека хорошая, но определенные моменты, с точки зрения использования, мне кажутся у меня более удачными и более прозрачными. Например, у меня, как мне кажется, все более единообразно, нет этого огромного количества методов stream - отдельно для стрима, ofList - отдельно для листов, у меня всегда единнобразно ObjectFactory.get(List.class, String.class) ObjectFactory.get(List.class, Person.class.

      P.S. В конечном итоге я решил пустить этот проект в большое плавание и только использование другими покажется насколько удачные решения я заложил в проект.


      1. dyadyaSerezha
        31.12.2023 07:39

        ObjectFactory.get(List.class, String.class)

        А дженерики тут вместо параметров никак?


        1. mathter Автор
          31.12.2023 07:39

          В текущей версии нет, в принципе можно передавать сразу java.lang.reflect.Type, но с точки зрения количества букв в коде это будет не сильно меньше, поскольку это самый Type как то необходимо сконструировать, например при помощи org.apache.commons.lang3.reflect.TypeUtils.

          objectFactory.get(Map<String, Person>.class)

          В scala думаю что так сделать можно.


  1. poxvuibr
    31.12.2023 07:39
    +1

    А есть где-нибудь сравнение библиотеки с аналогичными решениями. Я обычно easy random использую, есть ещё куча других по моему


    1. mathter Автор
      31.12.2023 07:39

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


  1. Anarchist
    31.12.2023 07:39
    +1

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


    1. mathter Автор
      31.12.2023 07:39

      Спасибо большое. Все больше начинаю понимать насколько Хабр - ценный ресурс!