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

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
4,187
Реакции
0
Баллы
36
Ofline
Меня зовут Максим, я 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
@Composable
fun ButtonCore(
    state: ButtonCoreState,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier) {
        // ...
    }
}

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


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


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

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

Код:
@CodegenModifier
@Composable
fun 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)
    |
    v
Frontend (FIR / K2)
    | синтаксический и семантический анализ
    v
IR (Intermediate Representation)
    | дерево функций, вызовов, параметров и выражений
    v
Backend
    | JVM / JS / Native
    v
Артефакты

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

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

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


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

Общая схема​


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


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


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


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


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


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


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

Код:
flowchart TB
    subgraph row1[ ]
        direction LR
        annotation["@CodegenModifier\nна функции"] --> scan["Обход IR-дерева"] --> check{"modifier\nи state\nесть?"}
    end
    check -->|нет| skip["Пропустить"]
    subgraph row2[ ]
        direction LR
        tag["Сформировать testTag\nиз имени функции"] --> analyze["Извлечь свойства state"] --> build["Построить\nmodifier.testTag().semantics{}"]
    end
    check -->|да| tag
    subgraph row3[ ]
        direction LR
        replace["Заменить использования\nmodifier в теле функции"] --> bytecode["Скомпилированный код"]
    end
    build --> replace

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


  • 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
@Composable
fun 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 от [B]state[/B] остаются на том же узле. Ручной 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-тесты на уровне экранов.
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru