Привет, Хабр! На связи Максим Кацал, руководитель направления автоматизированного тестирования в РТЛабс.

Мы с командой на Госуслугах тестируем огромное количество услуг. В каждой из них — тысячи, миллионы сценариев. Писать автотесты руками — безумие, а поддерживать их очень больно.

В статье расскажу, как мы научились генерировать тесты прямо из JSON-схемы услуги. JUnit 5 + @TestFactory + рекурсивный обход графа маршрутов. Фреймворк, который сам прокладывает путь пользователя по приложению-конструктору, а потом проигрывает его в браузере.

Ниже раскрою архитектуру, код, подводные камни, опишу плюсы и минусы такого подхода.

Почему пришлось это придумать

Больше 6 лет назад Госуслуги отказались от одностраничных услуг. Вместо этого появился конструктор форм — бэк + фронт. Он рисует услугу на лету по JSON-схеме.

В JSON описано всё:

  • структура экранов,

  • набор компонентов — поля, кнопки, чекбоксы,

  • логика переходов между экранами.

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

Первая попытка: стандартный подход и почему мы от него отказались

На старте выбрали классику: Java + Selenide + PageObject.

Тест выглядел так:

@Test
@DisplayName("По достижению 20 или 45 лет. По месту фактического проживания.")
void test() {
    passportRFPage.open()
        .submitNext()
        .select20or45()
        .selectAtThePlaceOfActualResidence()
        .submitListOfDocumentsForApplication()
        .submitValidatePassportData()
        .submitValidatePhoneNumber()
        .submitValidateEmail()
        .selectNoChildrenUnder14()
        .submitRequestRegion()
        .assertFinal();
}

Красиво? Да. Работает? Тоже да.

Но через полгода мы получили три проблемы.

Минусы подхода

  • Высокая стоимость поддержки

Услуг много, и они часто обновляются. Даже простая перестановка двух экранов ломает десятки тестов.

  • Проблемы с покрытием

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

  • Дублирование кода

Тесты отличаются только набором компонентов и переходов. Всё остальное — копипаста.

Инсайт: а ведь JSON знает всё! Ну, почти всё

За время работы мы глубоко погрузились в ядро системы. И поняли главное: в JSON услуги уже зашита большая часть информации для автоматической генерации маршрутов.

А ещё конструктор форм использует стандартные компоненты. Мы знаем, как они будут свёрстаны, по одному названию.

Значит, можно построить фреймворк, который:

  • читает JSON услуги,

  • сам вычисляет все возможные маршруты пользователя, фильтрует нужные,

  • превращает их в набор динамических тестов,

  • проигрывает эти маршруты в браузере.

За основу взяли динамические тесты в JUnit 5.

Архитектура фреймворка: быстро и по делу

Схема фреймворка
Схема фреймворка

Пояснение в двух словах:

  • RouteProcessor — из JSON строит все возможные маршруты,

  • DynamicTestBuilder — превращает маршруты в Stream<DynamicTest> для JUnit 5,

  • DynamicUiSteps — проигрывает маршрут в браузере.

Поехали по порядку.

Модуль 1. RouteProcessor: строим маршруты

Алгоритм — рекурсивный обход графа экранов. На каждом экране смотрим, какие ответы может дать пользователь. Под «ответами» я имею в виду не заполнение полей, а выбор вариантов, которые влияют на переходы — то, что описано в правилах перехода.

/**
 * @param count текущая вложенность рекурсии
 * @param current текущий экран
 * @param context класс, который содержит все ответа пользователя в текущем маршруте
 */
private void process(int count, String current, ExecutionContext context) {
    // Прерываем подсчет, если превысили вложенность рекурсии, либо превысили время подсчета, либо вычислили все необходимые маршруты
    if (checkEndConditions(count++)) {
        return;
    }
    // Получаем объект текущего экрана
    IndexedScreen currentScreen = service.get(current);
    // Получаем текущий маршрут
    Route route = context.getRoute();
    // Если экран финальный - добавляем в результаты текущий маршрут
    if (isFinishScreen(currentScreen)) {
        results.getRoutes().add(route);
        return;
    }
    try {
        // Получаем список доступных вариантов ответов на текущем экране
        List<Map<String, AppAnswer>> possibleAnswers = possibleAnswersEvaluator.getPossibleAnswers(currentScreen, context);
        if (possibleAnswers.isEmpty()) {
            throw new RuntimeException("Список ответов на экране %s пустой".formatted(current));
        }
        for (Map<String, AppAnswer> possibleAnswer : possibleAnswers) {
            // На каждый возможный ответ создаем новый контекст
            ExecutionContext newContext = new ExecutionContext(context);
            // Генерируем обвертку, с которой работает DynamicUiSteps
            ScreenSteps screenSteps = toScreenSteps(possibleAnswer, currentScreen, newContext);
            newContext.getRoute().addScreenSteps(context.getCurrentRole(), screenSteps);
            // Вычисляем следующий экран в зависимости от текущих ответов пользователя и правил перехода на следующие экраны
            String nextScreen = getNextScreen(currentScreen, newContext);
            // Рекурсивно вычисляем следующий экран
            process(count, nextScreen, newContext);
        }
    } catch (Exception e) {
        // Добавляем результат в список недостроенных маршрутов
        route.addScreenSteps(context.getCurrentRole(), toScreenSteps(emptyMap(), currentScreen, context));
        route.getAnswers().add("=[" + e.getMessage() + "]");
        results.getBrokenRoutes().add(route);
    }
}

Что может пойти не так

  • Следующий экран не найден.

  • Найдено несколько возможных переходов.

  • Ошибки при вычислении следующего экрана.

Виноват либо PossibleAnswersEvaluator — неправильно определил ответы, либо разработчик услуги — задал кривые правила. Такие маршруты попадают в brokenRoutes. Их мы отлаживаем и чиним отдельно.

Проблема миллиона маршрутов

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

Мы ввели вес маршрута: weight = elementsCount / routeLength, где:

  • elementsCount — количество непокрытых компонентов в маршруте,

  • routeLength — количество экранов в маршруте.

Чем больше новых компонентов и короче путь, тем выше вес. Так мы за минимальное число тестов покрываем максимум функциональности.

Плюс добавили отладочные фильтры:

  • найти самый длинный/короткий маршрут,

  • найти маршрут, проходящий через конкретный экран или компонент,

  • поиск по имени — удобно, если упал один конкретный кейс, и другие.

Модуль 2. DynamicUiSteps: проигрываем маршрут

Теперь, когда маршруты построены, запускаем UI-тесты.

Логика простая:

  1. Открываем услугу.

  2. Проходим по всем экранам из маршрута.

  3. Проверяем, что ID экрана в браузере совпадает с вычисленным.

  4. Заполняем все компоненты экрана по описанным правилам.

  5. В конце — валидируем XML и PDF, которые уходят в ведомство.

/**
 * Тело динамического теста
 *
 * @param route маршрут для данного теста
 */
private void dynamicTestBody(Route route) throws Throwable {
    boolean testPassed = true;
    // Проставляем в Allure данные о тесте
    updateAllure(feature.apply(route), story.apply(route), route);
    // Берем из настроек динамического теста тестовые данные
    T testData = testData.get();
    // Создаем инстанс тестового контекста для этого теста
    TestContext<T> testContext = new TestContext<>(testData, route, new SoftAssertions(), testSettings);
    // Сообщаем листенерам о том, что тест начался
    testListenerService.beforeTest(testContext);
    try {
        log.info("Test started: " + route);
        // Открываем услугу
        dynamicUiSteps.openService(testData.getApplicant(), service, additionalQueryParams, regionCode, cookies);
        // Выполняем все UI шаги для компонентов на экране текущего маршрута
        dynamicUiSteps.execute(testContext, uiSteps);
        // Проверяем сформированные заявления
        if (route.isProducesXml()) {
            Allure.step("Валидация XML и PDF", () -> requestValidator.accept(testContext));
        }
    } catch (Throwable t) {
        testPassed = false;
        throw testListenerService.testFailed(t);
    } finally {
        testListenerService.afterTest(testPassed);
    }
}

Как выполняются UI-шаги на одном экране:

public <T extends DynamicTestData> void execute(TestContext<T> context, Map<String, TestDataComponentSteps<T>> uiSteps) {
    // Проходим по всем экранам построенного маршрута
    for (ScreenSteps screenSteps : context.route().getScreenStepsList()) {
        // Один экран - один step в allure отчете. Все действия с компонентами на экране вложены в step
        step("Экран \"" + screenSteps.getScreen().getName() + "\" [" + screenSteps.getScreen().getId() + "]",
            () -> {
                try {
                    // Это экран-пустышка на нем ничего делать не нужно
                    if (screenSteps.getScreen().getType().equals("EMPTY")) {
                        return;
                    }
                    // На каждом экране записываем гифку
                    gifManager.startGifRecord();
                    // Выполняем подготовительные действия на экране: закрываем диклеймеры и т.п.
                    commonUiSteps.preScreenPrepare(Boolean.TRUE.equals(screenSteps.getScreen().getIsFirstScreen()));
                    // Проверяем, что ID экрана в браузере совпадает с ID вычисленного экрана
                    checkScreenId(screenSteps);
                    // Проверяем отсутствие плейсхолдеров на странице
                    validatePlaceholders(screenSteps);
                    // Пробегаемся по всем компонентам экрана и заполняем их. Если действия с компонентом не описаны, то останавливаем тест
                    screenSteps.getComponentStepsList()
                        .stream()
                        .filter(ComponentSteps::isInteractive)
                        .forEach(componentSteps ->
                            getComponentAction(screenSteps.getScreen().getId(), componentSteps.getComponent(), uiSteps)
                                .orElseThrow(() -> new TestAbortedException("UI step not found for component " + componentSteps.getComponent()))
                                .accept(componentSteps, context)
                        );
                    // Вычисляем и выполняем действие по переходу на следующий экран
                    processScreen(screenSteps);
                } catch (Exception t) {
                    log.error("Error on screen " + screenSteps.getScreen().getId() + " (" + screenSteps.getScreen().getName() + ")");
                    throw t;
                } finally {
                    gifManager.finishGifRecord("Gif-анимация экрана");
                }
            });
    }
}

Модуль 3. DynamicTestBuilder: склеиваем всё с JUnit 5

Самый важный вопрос: как подружить наши маршруты с JUnit 5?

Метод build() в DynamicTestBuilder возвращает Stream<DynamicTest>, теперь можем навесить аннотацию TestFactory в тестовый класс.

@Slf4j
@Setter
@Accessors(fluent = true)
public class DynamicTestBuilder<T extends DynamicTestData> {
    // Параметры определяют поведение билдера и доступны для редактирования пользователем через сеттеры
    /**
     * Вычисленные маршруты
     */
    Routes routes;
    /**
     * Функция, которая формирует название фичи для аллюра на основании маршрута
     */
    Function<Route, String> feature = s -> null;
    /**
     * Функция, которая формирует название стори для аллюра на основании маршрута
     */
    Function<Route, String> story = s -> null;
    /**
     * Фильтрация маршрутов в зависимости от текущего стенда (UAT, MOCK, PROD)
     */
    @Setter(AccessLevel.NONE)
    Map<Stand, Function<Routes, Routes>> routeFiltersByStand = new HashMap<>();
    /**
     * Конфиг для настройки RouteProcessor
     */
    RouteProcessorConfig routeProcessorConfig = new RouteProcessorConfig();
    /**
     * UI шаги, которые будут использоваться в тестах
     */
    Map<String, TestDataComponentSteps<T>> uiSteps = new HashMap<>();    
    /**
     * Супплаер для тестовых данных
     */
    Supplier<T> testData;
     /**
     * Метод для валидации XML и PDF
     */
    Consumer<TestContext<T>> requestValidator = r -> {
    };
    /**
     * Словарь для добавления дополнительных параметров в URL
     */
    Map<String, String> additionalQueryParams = null;
    /**
     * Кастомные куки, которые необходимы для открытия услуги
     */
    Map<String, String> cookies = new HashMap<>();
    /**
     * Отладочный параметр, позволяет запустить сломанные маршруты вместо нормальных<br/><b>Отработает только если
     * не передан параметр {@link DynamicTestBuilder#routes}</b>
     */
    boolean runBrokenRoutes = false;
 
    /**
     * Метод делает динамические тесты на основании переданных параметров
     *
     * @return Stream из динамических тестов для JUnit5
     */
    public Stream<DynamicTest> build() {
        RouteProcessorResults results = new RouteProcessor(indexedService, standUtils).process(routeProcessorConfig);
        routes = runBrokenRoutes ? results.getBrokenRoutes() : results.getRoutes();
        // Первым динамическим тестом мы вернём тест, в котором будет информация по маршрутам
        Stream<DynamicTest> jsonValidationTest = Stream.of(DynamicTest.dynamicTest("Построение маршрутов",
            () -> AllureUtils.attachToAllure("Результаты построения маршрутов", results.toString())));
        // Выводим в консоль шаблоны нереализованных ui шагов
        printUnimplemented();
        // Собираем stream динамических тестов
        Stream<DynamicTest> dynamicTestStream = routes.get().stream()
            .map(route -> DynamicTest.dynamicTest(firstNonEmpty(route.toString(), indexedService.getRaw().getService()),
                () -> dynamicTestBody(route)));
        return Stream.concat(jsonValidationTest, dynamicTestStream);
    }
}

Как выглядит тест на конкретную услугу:

public class ConvictionTest extends BaseDynamicTest<ConvictionTestData> {
 
    // Получаем всю необходимую инфу по услуге (номер, json и т.п.)
    @Override
    public EpguServices getEpguService() {
        return EpguServices.CRIMINAL_RECORD;
    }
 
    @Priority1
    @TestFactory
    Stream<DynamicTest> rfCitizen() {
        UserInfo applicant = esiaDataProvider.getCommonUser();
        return dynamicTest()
            .routeProcessorConfig(config()
                // Задаем данные для вычисления правил перехода, которые не можем получить из JSON услуги, (например, ответ от витрин)
                .applicationContext(new HashMap<>() {{
                    put("w10", "0");
                    put("pd4.value.regAddr.regionCode", "50");
                    put("restCallDepartments.value.status", "verify");
                }})
            )
            .feature(route -> "Гражданин РФ")
            .testData(() -> rfCitizenTestData(applicant))
            .uiSteps(getUiSteps())
            .requestValidator(this::requestValidator)
            // Фильтруем нужные нам маршруты
            .filterRoutes(r -> r.byScreenOrComponent("s1").longest())
            .build();
    }
 
    // Описываем тестовые данные, которые можем использовать в UI шагах
    private ConvictionTestData rfCitizenTestData(UserInfo applicant) {
        return new ConvictionTestData()
            .setApplicant(applicant)
            .setOrderId(lkDataProvider.getOrderId())
            .setSubjectRegistrationAddress(daDataProvider.getCommonEsiaRegistrationAddress())
            .setPreviousLastName("Фам" + randomRussian(integer(4, 7)))
            .setCountryApostil(nsiDataProvider.getCountryApostil())
            .setMvdOrg(nsiDataProvider.getMvdR010001())
            .setRegionalInformationCenterName(nsiDataProvider.getInfoCentreMvd());
    }
 
    // Описываем UI шаги для заполнения компонентов услуги
    private HashMap<String, TestDataComponentSteps<ConvictionTestData>> getUiSteps() {
        return new HashMap<>() {{
            /*<p>Выберите подразделение для подачи документов</p> MapService*/
            put("dict3_SanCheRest_is_fell" , (s, t) -> setValue(s, t.data().getMvdOrg()));
            /*<p>Информационный центр</p> MapService*/
            put("dict4_1" , (s, t) -> setValue(s, t.data().getRegionalInformationCenterName()));
            /*Адрес AddressInput*/
            put("pd17" , (s, t) -> regAddr.setIfNotFilled(randomLivingAddress()));
 
        }};
    }
 
    // Задаем правила проверки сформированных XML и PDF
    public void requestValidator(TestContext<ConvictionTestData> testContext) {
        Route route = testContext.route();
        ConvictionTestData testData = testContext.data();
 
        String smev3Request = requestValidationSteps
            .getSmev3RequestAsString(testData.getOrderId(),
                "schema/conviction/wcs-epgu-conviction-root.xsd");
 
        ConvictionRequestValidator.validateRequest(route, smev3Request, testData);
 
        requestValidationSteps.assertPdf(testData.getOrderId());
    }
 
}

Вместо привычных @Test — декларативное описание: какие данные брать, какие UI-шаги использовать, как валидировать результат.

ApplicationContext, или откуда берутся данные, которых нет в JSON

Внимательный читатель спросит: а откуда RouteProcessor знает значения, которые зависят от внешних систем? И будет прав.

По JSON услуги мы можем определить, какие кнопки нажал пользователь, какие поля заполнил и какие чекбоксы выбрал. Но есть правила переходов, которые завязаны на ответы от сторонних REST-запросов.

Например:

  • Показать следующий экран, если витрина вернула статус "verify".

  • Перейти на экран ошибки, если у пользователя нет нужного документа.

JSON об этом ничего не знает. А RouteProcessor — знает, но только если мы скормим ему эти значения вручную. Поэтому мы задаём в applicationContext ответы от сторонних сервисов, в этом состоит большая часть работы над автотестами услуги.

Особенности работы с UI-шагами

У каждого компонента на экране есть тип и ID. Мы можем указать поведение:

  • для всех компонентов определённого типа,

  • для конкретного компонента по ID.

return new HashMap<>() {{
    // По типу
    put("LabelSection", (s, t) -> nop());  // ничего не нужно заполнять
     
    // По ID
    put("dict3", (s, t) -> setValue(s, t.data().getMvdOrg())); // выбираем подразделение на карте
}};

Чтобы максимально упростить написание автотестов, мы сделали универсальный метод setValue, который определяет тип компонента и сам решает, как его заполнять:

switch (component.getType()) {
    case "StringInput":
    case "SnilsInput":
        setInput(component.getId(), (String) value);
        break;
    case "DateInput":
    case "CalendarInput":
        setDate(component.getId(), (LocalDate) value);
        break;
    case "DropDown":
        selectFromDropdown(labelWithoutTags, (String) value);
        break;
    // ... и так далее
}

Если стандартного поведения недостаточно — можно написать кастомную логику для заполнения компонента.

return new HashMap<>() {{
        put("id1", (s, t) -> {
            if (t.route().hasScreen("s_sumOfCapacities_3")) {
                driver.$(".label_one").click();
            } else {
                driver.$(".label_two").click();
            }
        });
    }};

DynamicTestListener

Для динамических тестов @BeforeEach из одной @TestFactory, по сути, является @BeforeAll — один раз для всех динамических тестов. Поэтому мы реализовали свои кастомные листенеры.

public interface DynamicTestListener {
    default void beforeTest(DynamicTestData testData) {}
    default void testFailed(Throwable throwable, DynamicTestData testData) {}
    default void afterTest(TestContext<?> testContext, boolean testPassed) {}
}

Листенеры вызываются в начале и конце теста или при падении в dynamicTestBody. Примеры листенеров:

  • аттач логов приложений при падении,

  • аттач черновиков заявлений,

  • работа со скриншотами и гифками,

  • работа с мокам.

Экспериментальные фичи

RouteProcessorHelper

Помощник, который анализирует сломанные маршруты и подсказывает, почему переход не найден. Отлично работает для простых правил. Для сложных — приходится «потеть» вручную.

UiStepsGenerator

Генератор UI-шагов на лету. Для простых компонентов работает без проблем. Но приходится дописывать руками, когда появляются:

  • регулярки в строках ввода,

  • зависимость видимости полей от значений других полей,

  • сложные валидации дат.

Плюсы и минусы подхода

Плюсы

  • Автоматическое покрытие: не нужно думать, какие сценарии писать.

  • Устойчивость к изменениям: перестановка экранов — тесты перегенерировались автоматически.

  • Минимальное количество кода тестов: вместо сотни тестов описываем одну тестовую фабрику и правила работы с ней.

  • Забыли про тест-дизайн — для скорости написания это плюс, для автотестировщиков скорее минус.

Минусы

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

  • В сложных услугах тратим много времени на задание контекста, чтобы «научить» RoutePrecessor строить маршруты.

  • Расхождение модели и реальности: в маршруте одна последовательность действий, а в браузере видим другую. Дебажить такое — долго и больно.

Заключение

Нам удалось создать фреймворк на основе динамических тестов Junit5, который позволяет автоматически генерировать маршруты из JSON услуги и проигрывать их в браузере. Динамические тесты оказались не просто техническим трюком, а новым способом мышления в тестировании. Мы перестали писать сценарии, мы начали описывать правила. Фреймворк реально работает на топ-50 услугах и активно используется при регрессионном тестировании.

Но важно понимать: наш подход работает только благодаря архитектуре Госуслуг, где услуга полностью описывается JSON-схемой: экраны, переходы, компоненты.

В типичном веб-приложении такой схемы нет — значит, и генерировать тесты не из чего. Поэтому наш опыт полезен в первую очередь:

  • командам, которые разрабатывают low-code/no-code платформы,

  • проектам с декларативным описанием UI,

  • всем, кто хочет посмотреть на тестирование под необычным углом.

Для обычных проектов этот подход скорее вдохновение, чем готовое решение.