В этой статье речь пойдет о создании тестов в java приложениях, в первую очередь unit-тестов, а точнее, будем говорить о генерации тестовых данных. Вообще, я считаю проблему генерации тестовых данных в тестировании центровой. Во-первых, необходимо осознать какие же данные нужны для теста, во-вторых, их необходимо подготовить и сгенерировать. На проектах уровня hello world или при очень хорошей декомпозии проблема невелика, но на больших проектах с большими DTO, это мало того что сложно, так еще и занудно. В какой-то момент количество кода теста может многократно превышать количество тестируемого кода.
Предпосылки
На одном из проектов, где я работал, часть сущностей для тестов (DTO и пр.) генерировалась автоматически при помощи PodamFactory
(https://mtedone.github.io/podam/) и, в принципе, все было хорошо. Но, во-первых, сущности содержали адовое количетво полей. Во-вторых, хотелось бы, чтоб данные выглядели правдоподобно, например, если поле представляет собой название города, то там было какое-то название города, а не случайный набор символов. Во-вторых, на данные могут накладываться какие то ограничения, простым примером может служить ограничение на длинну строки поля. В-третьих, данные формально могут быть преставлены строкой, но при этом могут содержать в себе некоторую структуру, скажем, ИНН клиента в первых двух символах содержит код субьекта РФ и так далее.
Пожелания
В общем, захотелось писать много всяких хороших тестов, причем быстро, поэтому в конце концов вырисовались нижеследующие требования к генератору тестовых данных:
Генерирует правдоподобные данные, не режущие глаз, например, если поле является названием города, то там какое либо знакомое название;
Генерирует данные по простейшим ограничениям, как то длина строк или максимальные/минимальные значения численных данных или, скажем, берет рандомно какое-то значение из перечисления (
java.lang.Enum
);Генерирует данные в соответствии с некотрой внутренней структой, к примеру, ИНН клиента или, скажем, номер банковского счета, в котором часть символов обозначают код валюты;
Если мы имеем сложный DTO, то иметь возможность большую часть полей сгенерировать по определенным общим правилам, а часть полей в данном месте теста описать более подробно, к примеру, 50 полей DTO объекта сгенерированы по общим условиям, а номер телефона задать конкретный, желаемый именно в данном месте теста;
Хотелось бы иметь максимально простой способ описания наших требований к тестовым данным, в идеале какой-то декларативный способ;
Хотелось бы из коробки иметь возможность генерировать экземпляры самых распространенных классов, во всяком случае те, что идут с 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)
ma1uta
31.12.2023 07:39+1Под все указанные требования подходит библиотка: https://github.com/instancio/instancio
Почему вы решили вместо неё писать свой вариант?
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. В конечном итоге я решил пустить этот проект в большое плавание и только использование другими покажется насколько удачные решения я заложил в проект.
dyadyaSerezha
31.12.2023 07:39ObjectFactory.get(List.class, String.class)
А дженерики тут вместо параметров никак?
mathter Автор
31.12.2023 07:39В текущей версии нет, в принципе можно передавать сразу
java.lang.reflect.Type
, но с точки зрения количества букв в коде это будет не сильно меньше, поскольку это самыйType
как то необходимо сконструировать, например при помощиorg.apache.commons.lang3.reflect.TypeUtils
.objectFactory.get(Map<String, Person>.class)
В scala думаю что так сделать можно.
dyadyaSerezha
1) Названия проекта и пакета по умолчанию - неудачные, особенно dasha
2) В начале заявлена цель - декларативность, а потом идёт сплошной код.
3) Неужели для такой распространенной задачи нашёлся только одна библиотека?
4) Для большинства сложных проектов используют слепки базы данных из прода, где в какой-то степени "замазаны" чувствительные данные. А иначе замучаешься генерить.
mathter Автор
1) Возможно, возможно стоит сделать ребрендинг :). Название dasha использовал в честь имени дочери, по праву создателя проекта.
2) Наверное согласен, пожалуй даже скорректирую текст;
3) Библиотеки как оказались есть, одну указали в одном из комментариев к статье https://github.com/instancio/instancio . Как ни странно, уже после начала работы над своим проектом я её не нашел, точнее в 2021 году ее еще не было. Нашел большое сходство идей, которые заложил в свою библиотеку я и авторы
instancio
. Но процесс уже пошел и решил продолжить работу - составить им конкуренцию. Некоторые вещи мне кажется я сделал более удачно, у меня естьDsl
у нихModel
. МойDsl
это в сущности собранные вместеModel
для использования это удобно;4) Эта тема очень обширная, для больших систем, интеграционных тестов и пр. действительно используются слепки БД с прода. Мы их тоже используем, на это все накладываются куча ограничений административных и ограничения безопасности и т.д и т.п. Я создавал библиотеку в которой в одном месте (`Dsl`) задает параметры для генерации объектов "на лету".