Kotlin IR Compiler Plugin в дизайн-системе: автотесты с Compose без ручной разметки

от автора

Меня зовут Максим, я Android-разработчик в команде дизайн-системы «БКС Мир инвестиций». В 2025 году у нас шёл большой редизайн: компонентная библиотека росла, команды подключали новые Compose компоненты, а вместе с этим быстро рос и объём UI-тестов.

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

Эта статья про то, как мы решили задачу через Kotlin IR Compiler Plugin. Снаружи решение выглядит почти незаметно: разработчик ставит одну аннотацию, а на этапе компиляции компонент автоматически получает стабильный testTag и тестовые semantics, собранные из его state. В результате у команды стало меньше бойлерплейта в компонентах, меньше риска рассинхронизации между state и тестами, а экранные UI-тесты получили более устойчивый контракт работы с дизайн-системой.

Проблема: верхнеуровневого testTag недостаточно

Когда мы начали разбирать вопрос «как реально тестировать Compose-экраны, собранные из наших компонентов», быстро выяснилось: просто покрыть компоненты testTag недостаточно.

Для экранного теста мало найти верхнеуровневый контейнер компонента. Нужно ещё понять, что именно компонент сейчас показывает и в каком состоянии находится. А здесь всплывала специфика нашей архитектуры: это не набор composable-функций с несколькими простыми параметрами, а библиотека компонентов со своими state-моделями и внутренними правилами.

Ключевые ограничения были такими:

  1. Практически у каждого компонента есть собственный state, а не россыпь отдельных параметров у @Composable-функции.

  2. Некоторые свойства компонента по смыслу не совпадают с «дефолтными» ожиданиями Compose. Например, isEnabled в дизайн-системе не всегда означает одинаковое визуальное поведение.

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

Сначала мы думали только про testTag. Уже после реализации я наткнулся на vkompose от VK и поймал знакомое ощущение “параллельной эволюции”: идея очень похожая, но мы пришли к ней в рамках своей задачи.

Однако в процессе обсуждения стало понятно, что вопрос на самом деле не один, а сразу несколько:

  1. Как стабильно «достучаться» до нужного компонента в автотестах?

  2. Как надёжно читать его значимые характеристики внутри теста?

  3. Как тесту понять текущее состояние компонента: Loading, Empty, Data или что-то ещё?

Именно в этот момент задача перестала быть историей только про теги. Нам понадобился механизм, который давал бы экранному тесту стабильную точку входа в компонент и доступ к его актуальному состоянию.

Здесь важно уточнить ещё одну вещь. В нашей дизайн-системе источник истины для компонента — это не набор финальных визуальных значений вроде #0052CC и 12.dp, а state с внутренними типами и токенами. Поэтому тесту в большинстве случаев нужно проверить не «какой именно hex-цвет сейчас нарисован», а «какой режим компонента активен», «какой токен/вариант применён» и «какие смысловые признаки состояния сейчас выставлены».

Иными словами, нам нужно было проецировать тестовый контракт компонента из state в рантайм так, чтобы тест мог читать его стабильно и без знания внутренней вёрстки.

К semantics мы пришли не сразу. Сначала это была гипотеза: если мы уже умеем внедряться в компиляцию, можем ли мы там же автоматически положить в компонент часть данных из state, чтобы тест их читал в рантайме? Гипотеза сработала.

Почему именно semantics? Потому что в Compose это уже существующий runtime-канал на уровне узла, с которым штатно работают UI-тесты. testTag хорошо решает адресацию: он помогает найти нужный компонент. Но чтобы прочитать у этого компонента значимые признаки состояния, нужен второй слой контракта. Semantics — встроенный механизм Compose: они привязаны к узлу Compose-дерева, доступны тестовым матчером и позволяют проверять компонент по его контракту, а не по деталям внутренней разметки.

На сам подход нас дополнительно вдохновил и внешний опыт, например материал Яндекса: https://habr.com/ru/companies/yandex/articles/978126/

Почему не сработали обычные подходы

Прежде чем идти в compiler plugin, мы перебрали более приземлённые варианты.

Ручные testTag и semantics

Это самый очевидный путь, но на масштабе дизайн-системы он быстро превращается в дисциплинарную задачу:

  • нужно договориться о схеме имён;

  • не забывать поддерживать её в каждом новом компоненте;

  • синхронно обновлять semantics при изменении state;

  • ловить ошибки на code review.

На двух-трёх компонентах это терпимо. На десятках и сотнях это уже постоянный источник рассинхронизации.

Helper-функции и extension-обвязка

Мы могли бы договориться, что каждый компонент обязан вызывать что-то вроде modifier.withGeneratedTestContract(state). Но разработчик всё равно должен помнить об инфраструктурной детали, а тестовый контракт всё равно можно забыть применить.

KSP и KAPT

Первый логичный вопрос: зачем вообще лезть в компилятор, если уже есть KSP и KAPT?

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

KSP и KAPT хорошо подходят, когда нужно создать дополнительные файлы, классы, фабрики или обвязку. Но они не умеют взять существующую @Composable-функцию, найти внутри неё использование modifier и переписать это использование так, чтобы туда автоматически добавились .testTag() и .semantics {}.

А нам нужно было именно это.

Page Object без compiler plugin

Page Object решает часть проблемы на уровне тестов, но не решает источник нестабильности в самих компонентах. Если у компонента нет стабильного тестового контракта, Page Object всё равно будет зависеть от внутренней вёрстки или внешних договорённостей.

Иными словами, нам нужна была compile-time автоматизация, а не ещё одно правило, которое легко забыть при разработке компонента.

Идея: генерировать тестовый контракт на этапе компиляции

Снаружи всё очень просто: разработчик пишет обычный компонент и добавляет к нему одну аннотацию.

@Target(AnnotationTarget.FUNCTION)@Retention(AnnotationRetention.SOURCE)annotation class CodegenModifier(    val customTag: String = "")

Использование выглядит так:

@CodegenModifier@Composablefun ButtonCore(    state: ButtonCoreState,    modifier: Modifier = Modifier,) {    Box(modifier = modifier) {        // ...    }}

Во время компиляции плагин делает две вещи:

  1. Генерирует стабильный testTag.

  2. Добавляет в semantics данные из state, чтобы тест мог их прочитать.

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

@CodegenModifier@Composablefun ButtonCore(    state: ButtonCoreState,    modifier: Modifier = Modifier,    content: @Composable BoxScope.() -> Unit,) {    Box(        modifier = modifier            .clip(state.shape)...,        contentAlignment = Alignment.Center    ) {        content()    }}

А после компиляции ключевой фрагмент modifier-цепочки фактически превращается в такой:

...modifier    .testTag("button_core")    .semantics(mergeDescendants = false) {        set(SemanticsPropertyKey<String>("backgroundColor"), state.backgroundColor.toString())        set(SemanticsPropertyKey<String>("borderColor"), state.borderColor.toString())        set(SemanticsPropertyKey<String>("size"), state.size.name)    }    .clip(state.shape)...,...

Важно здесь вот что:

  • разработчик по-прежнему пишет обычный Compose-код;

  • существующие Modifier-цепочки не приходится переписывать;

  • уже добавленные вручную semantics не ломаются;

  • экранный тест получает предсказуемый тег и доступ к данным, производным от state.

По умолчанию тег строится из имени функции, но мы добавили и customTag для случаев, когда тестовый контракт должен быть стабильнее имени конкретной реализации.

Почему для этого понадобился Kotlin IR

На уровне идеи задача выглядела так:

  1. Найти функцию с @CodegenModifier.

  2. Найти в ней modifier.

  3. Найти state.

  4. Извлечь свойства state.

  5. Построить новую цепочку модификаторов вида modifier.testTag(...).semantics { ... }.

  6. Подменить этой цепочкой реальные места использования modifier в теле функции.

Такой уровень контроля появляется только на уровне IR, промежуточного представления программы внутри Kotlin compiler pipeline.

Kotlin compiler pipeline в двух словах

Source (.kt)    |    vFrontend (FIR / K2)    | синтаксический и семантический анализ    vIR (Intermediate Representation)    | дерево функций, вызовов, параметров и выражений    vBackend    | JVM / JS / Native    vАртефакты

На фазе IR у компилятора уже есть подробное дерево программы, но код ещё можно менять. Именно там и стало возможным внедрить наш тестовый контракт.

Отдельно было важно, что плагин у нас сразу работает с новым фронтендом: он объявляет supportsK2 = true. Для решения, которое должно жить не один месяц, это принципиально.

Как это устроено внутри плагина

Если совсем без магии, то внутри это довольно прямолинейный pipeline.

Общая схема

Плагин проходит по функциям в IR-дереве. Если видит @CodegenModifier, он делает несколько последовательных шагов:

  1. Проверяет, есть ли подходящий modifier.

  2. Ищет параметр state.

  3. Строит итоговый тег из имени функции или берёт customTag.

  4. Извлекает свойства state.

  5. Собирает новый modifier: testTag + semantics.

  6. Подменяет им реальные использования modifier внутри тела функции.

Если смотреть на исходники, основные роли такие:

  • CodegenIrGenerationExtension запускает обработку в фазе IR;

  • ModifierExtensionsInjector находит функции с @CodegenModifier;

  • StateAnalyzer извлекает свойства state;

  • SemanticsIrModifierCodeGenerator и SemanticsLambdaBuilder собирают новый modifier;

  • DefaultModifierUsageHandler подменяет реальные использования modifier в теле функции.

Что именно переписывается в теле функции

У modifier внутри компонента может быть несколько форм использования. Например:

  • он передаётся как value argument: Box(modifier = modifier);

  • он используется как receiver для цепочки: modifier.padding(...).

Для нас было важно поддержать оба случая. Иначе решение оказалось бы слишком хрупким и зависимым от стиля написания компонента.

DefaultModifierUsageHandler сначала собирает все точки использования modifier, а потом заменяет их переписанным выражением. Это позволило не ограничиваться только самым простым случаем с modifier = modifier.

Отдельно пришлось учитывать и неприятные частные случаи:

  • уже существующие ручные вызовы .semantics {} в modifier-цепочке;

  • несколько последовательных преобразований modifier внутри функции;

  • риск коллизий тегов, если имя функции меняется, а тестовый контракт должен остаться прежним.

Именно для последнего случая у аннотации появился customTag.

Почему semantics оказались самой сложной частью

С testTag() всё сравнительно просто: это обычный вызов функции-расширения. В IR нужно найти её символ и передать строковый аргумент.

С semantics {} история сложнее.

На уровне Kotlin-кода это выглядит как аккуратная trailing lambda. Но в IR такую конструкцию приходится собирать вручную: создавать анонимную функцию, корректно выставлять receiver, находить нужные символы SemanticsPropertyKey и set, а затем для каждого свойства state строить отдельный вызов записи в semantics.

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

Именно поэтому SemanticsLambdaBuilder оказался самой чувствительной частью решения: если ошибиться в symbol resolution или в устройстве receiver внутри лямбды, получится либо неверное поведение, либо трудноуловимые падения на этапе компиляции или выполнения.

Как state превращается в semantics

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

Для каждого свойства плагин строит:

  1. SemanticsPropertyKey по имени свойства.

  2. Вызов set(key, value) внутри semantics.

  3. Конвертацию значения в безопасную форму для записи.

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

Тип свойства

Что записываем в semantics

String non-null

как есть

String nullable

.toString()

Enum

.name, fallback -> .toString()

Boolean, Int, Float

.toString()

Объекты (собственные типы)

безопасная строковая форма

Сложные и вложенные типы

не сериализуем автоматически

Это важный инженерный компромисс. Мы не пытались превратить semantics в универсальный сериализатор всего state: часть значений сознательно приводим к строковому виду, а сложные структуры не экспортируем автоматически.

Принцип «матрёшки»: сложные структуры через вложенные компоненты

Компоненты дизайн-системы собираются друг из друга. Внешний компонент (ButtonBase) внутри вызывает более примитивный (ButtonCore), и оба помечены @CodegenModifier. Получается иерархия: каждый уровень — отдельный узел в Compose-дереве со своим тестовым контрактом.

Посмотрим на реальный пример. ButtonBase — это кнопка с текстом, лоадером и визуальным стилем. Внутри она вызывает ButtonCore, который отвечает за подложку (фон, скругления, тень):

@CodegenModifier@Composablefun ButtonBase(    state: ButtonBaseState,    modifier: Modifier = Modifier,    actions: ButtonBaseActions = ButtonBaseActions.Empty,) {    // ...    ButtonCore(        modifier = buttonModifier,        state = state.coreState  // сложный вложенный объект    ) {        ButtonLabelBase(state.buttonLabelBaseState)        if (state.isPending && state.isEnabled) {            LoaderCore(state.loaderState)        }    }}

В ButtonBaseState есть свойство coreState типа ButtonCoreState — это сложный объект с вложенными типами (ColorState, Shape, PaddingState, BorderState). Плагин не сериализует его автоматически на уровне ButtonBase, потому что ButtonCore сам помечен @CodegenModifier и экспортирует свои параметры отдельно.

Визуально это выглядит так:

ButtonBase (testTag = "button_base")├── semantics: isEnabled, isPending, size, style, hierarchyStyle│└── ButtonCore (testTag = "button_core")    ├── semantics: backgroundColor, height, border, shadow    │    ├── ButtonLabelBase (testTag = "button_label_base")    └── LoaderCore (testTag = "loader_core")

В тесте это даёт естественную навигацию. Сначала находим кнопку по экранному тегу и проверяем её параметры:

composeTestRule    .onNodeWithTag("buy_button")    .assert(SemanticsMatcher.expectValue(isEnabledKey, "true"))    .assert(SemanticsMatcher.expectValue(styleKey, "Brand"))    .assert(SemanticsMatcher.expectValue(sizeKey, "L"))

Если нужно проверить параметры подложки — ищем ButtonCore внутри ButtonBase:

composeTestRule    .onNodeWithTag("buy_button")    .onChildren()    .filterToOne(hasTestTag("button_core"))    .assert(SemanticsMatcher.expectValue(backgroundColorKey, "BrandDefault"))

Принцип прост: каждый уровень «матрёшки» — это самостоятельный @CodegenModifier-компонент со своим контрактом. Внешний экспортирует свои параметры, внутренний — свои. Тест не разбирает вёрстку и не зависит от внутренних деталей реализации — он идёт к нужному уровню по тегу и читает контракт. Свойства, которые являются сложными структурами, не «пропадают» — они просто принадлежат своему уровню иерархии.

Ручной testTag: приоритет и доступность semantics

До этого мы видели автосгенерированные теги вида "button_base". Но на реальном экране разработчик обычно добавляет свой testTag — чтобы однозначно идентифицировать конкретный экземпляр компонента среди других:

ButtonBase(    state = buyState,    modifier = Modifier.testTag("buy_button"))

Возникает вопрос: что происходит, когда плагин автоматически добавляет .testTag("button_base"), а разработчик уже передал .testTag("buy_button")?

Мы это предусмотрели: при наличии ручного testTag доступ к узлу остаётся именно по экранному тегу, а не по автосгенерированному. Автотег "button_base" через onNodeWithTag уже не находится:

Это подтверждается нашими тестами:

composeTestRule    .onNodeWithTag("manual_test_tag")    .assertIsDisplayed()composeTestRule    .onNodeWithTag("button_base")    .assertDoesNotExist()

При этом semantics от state остаются на том же узле. Ручной testTag перекрывает автосгенерированный тег, но не влияет на контракт компонента:

composeTestRule    .onNodeWithTag("buy_button")    .assert(SemanticsMatcher.expectValue(isEnabledKey, "true"))    .assert(SemanticsMatcher.expectValue(styleKey, "Brand"))    .assert(SemanticsMatcher.expectValue(sizeKey, "L"))

Итого: ручной testTag выигрывает у автоматического, но данные из state остаются доступны тесту.

Пример: типы свойств на одном компоненте

Вот реальный state из дизайн-системы — IconButtonGhostBaseState:

sealed interface IconButtonGhostBaseState : NeptuneComposeState {    ...    data class State(            ...            override val id: String = "",            override val isEnabled: Boolean = true,            override val isPending: Boolean = false,            val style: Style = Brand,    ) : IconButtonGhostBaseState    enum class Style { Brand, Neutral, StaticWhite }}

Здесь несколько типов свойств. Плагин для каждого подбирает свою стратегию:

Свойство

Тип

Что попадёт в semantics

id

String

как есть

isEnabled

Boolean

.toString()

isPending

Boolean

.toString()

style

Enum

.name → “Brand”

Тесту достаточно проверить isEnabled = “true”, isPending = “false”, style = “Brand”.

Почему это не ломает accessibility

Когда говоришь «мы кладём данные в semantics», следующий ожидаемый вопрос звучит так: а не начинаете ли вы смешивать accessibility и тестовую инфраструктуру? Поэтому у нас здесь были жёсткие ограничения.

Во-первых, мы не подменяем существующие accessibility semantics компонента. Ручные вызовы вроде semantics { role = Role.Button } остаются на месте и продолжают описывать поведение для accessibility.

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

В-третьих, мы явно контролируем точку встраивания и не включаем агрессивное слияние потомков. В нашем случае mergeDescendants = false был осознанным выбором: тесту нужна стабильная точка входа на уровне самого компонента, а не попытка растворить тестовый контракт в объединённом semantics-дереве.

Если бы наша цель была расширять accessibility-модель, а не тестовый контракт, требования к дизайну решения были бы другими.

Почему решение ограничено debug-сборками

Для нас было принципиально, чтобы эта инфраструктура не протекала в release-артефакты.

Поэтому плагин подключается только в debug-сценариях сборки, где реально запускаются UI-тесты и где тестовый контракт приносит пользу. В release-сборке этот слой просто не применяется: компонент компилируется без автоматически добавленных testTag и test-only semantics.

Это важно по двум причинам:

  1. Мы не хотим тащить служебную тестовую разметку в production-артефакт без реальной пользы для пользователя.

  2. Мы уменьшаем риск того, что тестовая инфраструктура начнёт влиять на runtime-поведение release-сборки.

На практике такую границу обязательно нужно верифицировать отдельно: проверять конфигурацию Gradle, смотреть состав debug/release артефактов и прогонять регрессионные тесты после обновления Kotlin или Compose.

Ограничения и цена сопровождения

Конечно, такое решение не снимает вообще все вопросы разом.

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

  • Мы сознательно ограничиваем автоматически экспортируемые данные и не пытаемся сериализовать в semantics весь state целиком.

  • Решение чувствительно к эволюции Kotlin IR API и Compose symbols, поэтому обновления Kotlin и Compose требуют регрессионной проверки.

  • Сам compiler plugin тоже требует инфраструктуры вокруг себя: тестов на rewrite, проверок symbol resolution и отдельных сценариев совместимости.

Это важный момент. Если у вас 10 composable-функций и нет общей компонентной библиотеки, compiler plugin почти наверняка будет избыточен. Цена владения у него выше, чем у обычного helper-а.

Но на масштабе дизайн-системы картина меняется. Чем больше компонентов, экранов и команд завязаны на единый тестовый контракт, тем быстрее compile-time автоматизация начинает окупаться.

Что это дало команде

Сейчас у нас уже более 150 компонентов в дизайн-системе покрыты @CodegenModifier. Но важнее не сама цифра, а то, что изменилось в рабочем процессе.

На практике мы получили:

  • единый стандарт формирования testTag;

  • меньше ручного boilerplate в компонентах;

  • меньший риск рассинхронизации между state и тестовой semantics-разметкой;

  • более предсказуемую точку входа для экранных UI-тестов;

  • более простой onboarding новых разработчиков в компонентный слой.

Если коротко, compile-time автоматизация начала окупаться тем сильнее, чем больше становилась сама библиотека компонентов и чем больше экранов начинали от неё зависеть.

Что дальше

Следующий шаг, который мы хотим попробовать на базе того же подхода, это генерация Page Object.

Идея в том, чтобы использовать тот же compile-time pipeline для автоматического создания готовых тестовых обёрток вокруг компонентов. Тогда можно получить:

  • Page Object «из коробки» без ручного написания однотипного кода;

  • ещё более устойчивую изоляцию тестов от внутренней эволюции компонентов;

  • более удобный контракт для тестировщиков, которым не нужно каждый раз заново изучать внутреннее устройство компонента.

Если эта идея взлетит, то @CodegenModifier станет не только способом автоматически добавлять testTag и semantics, но и точкой входа в более широкую test-инфраструктуру вокруг компонентной библиотеки.

Краткий итог

В нашем случае compiler plugin оказался не способом «ещё как-нибудь навесить testTag», а способом зафиксировать стабильный тестовый контракт компонента на этапе компиляции. Именно это и дало основной эффект: меньше boilerplate, меньше рассинхронизации и более предсказуемые UI-тесты на уровне экранов.

ссылка на оригинал статьи https://habr.com/ru/articles/1029496/