Home | RU | EN

Кастомный генератор сущностей

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

Проблема текущего подхода

Например, так это выглядит в тесте:

User user = User.builder()
                .username(RandomData.getUsername())
                .password(RandomData.getPassword())
                .role(UserRole.USER.toString())
                .build();

Тут (выше), кстати, мы используем самописный класс RandomData:

public class RandomData {
    private RandomData() {
    }

    public static String getUsername() {
        return RandomStringUtils.randomAlphabetic(4, 15);
    }

    public static String getPassword() {
        return RandomStringUtils.randomAlphabetic(6).toLowerCase() +
                RandomStringUtils.randomAlphabetic(3).toUpperCase() +
                RandomStringUtils.randomNumeric(3) + "!";
    }
}

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

Решение: Кастомный генератор

1. Утилитарный класс RandomModelGenerator

Сначала надо создать утилитарный класс RandomModelGenerator с соответствующими static методами. Мой например выглядит так, не стоит долго в нем разбираться, он нужен в качестве примера для AI.

Этот класс - генератор случайных данных для тестирования. Он автоматически создает объекты любых классов и заполняет их поля случайными значениями.

Важно: Класс использует стороннюю библиотеку Generex для генерации строк по регулярным выражениям. Альтернативно можно использовать библиотеку RgxGen. Главное - прописать это в промпте для AI при генерации кода.

Как это работает простыми словами:

  1. Основная задача
    Представьте, что у вас есть класс User с полями name, age, email. Вместо того чтобы вручную создавать тестовые данные:
    User user = new User();
    user.setName("Иван");
    user.setAge(25);
    user.setEmail("ivan@test.com");
    Этот класс делает это автоматически:
    User user = RandomModelGenerator.generateRandomModel(User.class);
    // Получаете объект с уже заполненными случайными данными
  2. Как он работает пошагово:
    • Шаг 1: Создание объекта - Берет любой класс (например, User) и создает новый экземпляр этого класса
    • Шаг 2: Анализ полей - Проходит по всем полям класса (включая поля родительских классов), игнорирует статические поля и уже заполненные поля
    • Шаг 3: Заполнение полей - Для каждого поля есть два варианта:
      • Вариант А: Поле с аннотацией @RegexGen - Генерирует строку по регулярному выражению с определенной вероятностью может сделать поле null
      • Вариант Б: Обычное поле - Генерирует случайное значение по типу поля
    • Шаг 4: Рекурсивное заполнение - Если поле - это другой объект, то рекурсивно заполняет и его тоже
public final class RandomModelGenerator {
    private static final ThreadLocalRandom RND = ThreadLocalRandom.current();

    private RandomModelGenerator() {}

    public static <T> T generateRandomModel(Class<T> clazz) {
        try {
            T instance = newInstance(clazz);
            fillObject(instance);
            return instance;
        } catch (Exception e) {
            throw new RuntimeException("Failed to generate model for " + clazz.getName(), e);
        }
    }

    // --- внутренности ---

    private static void fillObject(Object obj) throws Exception {
        if (obj == null) return;

        for (Field f : getAllFields(obj.getClass())) {
            if (Modifier.isStatic(f.getModifiers())) continue;
            f.setAccessible(true);

            // не трогаем уже установленные значения (если кто-то builder-ом подставил заранее)
            if (f.get(obj) != null) continue;

            Class<?> type = f.getType();
            RegexGen rg = f.getAnnotation(RegexGen.class);

            if (rg != null) {
                // вероятность null
                if (rg.nullProbability() > 0 && RND.nextDouble() < rg.nullProbability()) {
                    f.set(obj, null);
                    continue;
                }

                String generated = new Generex(rg.value()).random();
                Object coerced = coerceToType(generated, type);
                f.set(obj, coerced);
                continue;
            }

            // без аннотации - ставим дефолтные случайные/пустые значения или рекурсивно заполняем вложенную модель
            f.set(obj, defaultValueFor(type));
        }
    }

    private static Object coerceToType(String s, Class<?> type) throws Exception {
        if (type == String.class) return s;
        if (type == Integer.class || type == int.class) return Integer.parseInt(s);
        if (type == Long.class || type == long.class) return Long.parseLong(s);
        if (type == Double.class || type == double.class) return Double.parseDouble(s);
        if (type == Boolean.class || type == boolean.class) return Boolean.parseBoolean(s);

        if (type == LocalDate.class) return LocalDate.parse(s);             // ожидаем ISO-формат yyyy-MM-dd
        if (type == LocalDateTime.class) return LocalDateTime.parse(s);     // ожидаем ISO-формат yyyy-MM-ddTHH:mm:ss

        if (type.isEnum()) {
            @SuppressWarnings({"rawtypes", "unchecked"})
            Enum value = Enum.valueOf((Class<Enum>) type, s);
            return value;
        }

        // Вложенный объект: если строку нельзя привести - пробуем рекурсивно создать объект
        Object nested = tryCreateAndFillNested(type);
        if (nested != null) return nested;

        // fallback - оставить строку, если поле String, иначе null
        return type == String.class ? s : null;
    }

    private static Object defaultValueFor(Class<?> type) throws Exception {
        if (type == String.class) return randomAlphaNum(8);
        if (type == Integer.class || type == int.class) return RND.nextInt(0, 10_000);
        if (type == Long.class || type == long.class) return Math.abs(RND.nextLong());
        if (type == Double.class || type == double.class) return RND.nextDouble();
        if (type == Boolean.class || type == boolean.class) return RND.nextBoolean();
        if (type == LocalDate.class) return LocalDate.now().plusDays(RND.nextInt(0, 30));
        if (type == LocalDateTime.class) return LocalDateTime.now().plusMinutes(RND.nextInt(0, 60));
        if (type.isEnum()) {
            Object[] constants = type.getEnumConstants();
            return constants.length == 0 ? null : constants[RND.nextInt(constants.length)];
        }

        // Коллекции и массивы - по-простому: пустые
        if (Collection.class.isAssignableFrom(type)) return Collections.emptyList();
        if (type.isArray()) return Array.newInstance(type.getComponentType(), 0);

        // Считаем, что это вложенная модель
        return tryCreateAndFillNested(type);
    }

    private static Object tryCreateAndFillNested(Class<?> type) throws Exception {
        try {
            Object nested = newInstance(type);
            fillObject(nested);
            return nested;
        } catch (Exception ignored) {
            return null;
        }
    }

    private static <T> T newInstance(Class<T> type) throws Exception {
        Constructor<T> ctor = type.getDeclaredConstructor();
        ctor.setAccessible(true);
        return ctor.newInstance();
    }

    private static List<Field> getAllFields(Class<?> type) {
        List<Field> fields = new ArrayList<>();
        for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
            fields.addAll(Arrays.asList(c.getDeclaredFields()));
        }
        return fields;
    }

    private static String randomAlphaNum(int len) {
        final String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        StringBuilder sb = new StringBuilder(len);
        for (int i = 0; i < len; i++) sb.append(alphabet.charAt(RND.nextInt(alphabet.length())));
        return sb.toString();
    }
}

2. Аннотация @RegexGen

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

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface RegexGen {
    /** Регулярное выражение для генерации значения поля */
    String value();

    /** Вероятность сделать поле null (0.0..1.0). По умолчанию не null. */
    double nullProbability() default 0.0;
}

3. Использование в DTO классе

В DTO классе проставить данную аннотацию и regexp над каждым полем, которое нужно генерировать:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BookingModel extends BaseModel{
    @RegexGen("[A-Z][a-z]{3,10}")
    public String firstname;

    @RegexGen("[A-Z][a-z]{3,12}")
    public String lastname;

    @RegexGen("\\d{2,4}")
    public Integer totalprice;

    @RegexGen("true|false")
    public Boolean depositpaid;

    public BookingdatesModel bookingdates;

    @RegexGen("Breakfast|Dinner|Late checkout")
    public String additionalneeds;
}

4. Использование в тестах

В тестах просто пишем:

BookingModel model = RandomModelGenerator.generateRandomModel(BookingModel.class);

Преимущества подхода

Готовый промпт для AI

Создай мне кастомный генератор сущностей RandomModelGenerator со статическим методом generateRandomModel(), который в качестве параметра принимает DTO класс и создает объект этого класса сразу генерируя значения для его полей на основе аннотации @RegexGen, навешенной на поле этого класса с указанием regexp, например так:
@RegexGen("[A-Z][a-z]{3,10}")
  public String firstname;
Если аннотации нет, то не генерируй никакого значения для поля. Чтобы я мог написать
User user = RandomModelGenerator.generateRandomModel(User.class)
и создался объект класса User с сгенерированными полями. Можешь использовать стороннюю библиотеку com.github.curious-odd-man/rgxgen. Также добавь возможность чтобы можно было рандомно выбирать значения из существующих в проекте Enum. И чтобы если в качестве поля в DTO классе используется другой вложенный класс, то для него так же генерировались значения.

Данный промпт генерирует все что надо + примеры использования. Кроме того, потом вы всегда можете расширить функциональность данного класса, попросив добавить возможность генерации дат и времени в определенном формате и так далее. В идеале надо использовать AI с контекстом вашего проекта (типа Cursor).