
Привет, Хабр! На связи Максим Кацал, руководитель направления автоматизированного тестирования в РТЛабс.
Мы с командой на Госуслугах тестируем огромное количество услуг. В каждой из них — тысячи, миллионы сценариев. Писать автотесты руками — безумие, а поддерживать их очень больно.
В статье расскажу, как мы научились генерировать тесты прямо из 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-тесты.
Логика простая:
Открываем услугу.
Проходим по всем экранам из маршрута.
Проверяем, что ID экрана в браузере совпадает с вычисленным.
Заполняем все компоненты экрана по описанным правилам.
В конце — валидируем 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,
всем, кто хочет посмотреть на тестирование под необычным углом.
Для обычных проектов этот подход скорее вдохновение, чем готовое решение.
