Генератор тестовых данных для JVM совместимых языков

В этой статье речь пойдет о создании тестов в 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:


		org.genthz
		genthz-core
		3.1.3 

Далее, получает объект класса 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 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 map = objectFactory.get(
  Map.class,
  String.class,
  Person.class
);

fcc4d7e5a61be107fef5cc2dcd57164b.png

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

public class FuriousGenerics {
	private A field0;

	private Map> fiedl1;

	private Deque> field2;
}
FuriousGenerics, Collection>, List> 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
        )
);

530c7b2a78b0683c3d6ebc0869483862.png

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

По умолчанию размер генерируемых коллекций возвращается методом 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.

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

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

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

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

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

© Habrahabr.ru