- Регистрация
- 23 Авг 2023
- Сообщения
- 4,187
- Реакции
- 0
- Баллы
- 36
Ofline
Меня зовут Максим, я Android-разработчик в команде дизайн-системы «БКС Мир инвестиций». В 2025 году у нас шёл большой редизайн: компонентная библиотека росла, команды подключали новые Compose компоненты, а вместе с этим быстро рос и объём UI-тестов.
Для команды это быстро стало не абстрактной инженерной задачей, а вопросом скорости и стабильности разработки. Нужно было дать тестировщикам единый способ находить компоненты на экране и проверять их состояние, не заставляя разработчиков вручную поддерживать одинаковую тестовую разметку в каждом компоненте.
Эта статья про то, как мы решили задачу через Kotlin IR Compiler Plugin. Снаружи решение выглядит почти незаметно: разработчик ставит одну аннотацию, а на этапе компиляции компонент автоматически получает стабильный
Когда мы начали разбирать вопрос «как реально тестировать Compose-экраны, собранные из наших компонентов», быстро выяснилось: просто покрыть компоненты
Для экранного теста мало найти верхнеуровневый контейнер компонента. Нужно ещё понять, что именно компонент сейчас показывает и в каком состоянии находится. А здесь всплывала специфика нашей архитектуры: это не набор composable-функций с несколькими простыми параметрами, а библиотека компонентов со своими
Ключевые ограничения были такими:
Сначала мы думали только про
Однако в процессе обсуждения стало понятно, что вопрос на самом деле не один, а сразу несколько:
Именно в этот момент задача перестала быть историей только про теги. Нам понадобился механизм, который давал бы экранному тесту стабильную точку входа в компонент и доступ к его актуальному состоянию.
Здесь важно уточнить ещё одну вещь. В нашей дизайн-системе источник истины для компонента - это не набор финальных визуальных значений вроде
Иными словами, нам нужно было проецировать тестовый контракт компонента из
К
Почему именно
На сам подход нас дополнительно вдохновил и внешний опыт, например материал Яндекса: Тепловизор для разработчика: подсвечиваем рекомпозиции прямо в коде https://habr.com/ru/companies/yandex/articles/978126/
Прежде чем идти в compiler plugin, мы перебрали более приземлённые варианты.
Это самый очевидный путь, но на масштабе дизайн-системы он быстро превращается в дисциплинарную задачу:
На двух-трёх компонентах это терпимо. На десятках и сотнях это уже постоянный источник рассинхронизации.
Мы могли бы договориться, что каждый компонент обязан вызывать что-то вроде
Первый логичный вопрос: зачем вообще лезть в компилятор, если уже есть KSP и KAPT?
Короткий ответ: потому что нам нужно было не сгенерировать новый код рядом, а изменить поведение уже написанной функции.
KSP и KAPT хорошо подходят, когда нужно создать дополнительные файлы, классы, фабрики или обвязку. Но они не умеют взять существующую
А нам нужно было именно это.
Page Object решает часть проблемы на уровне тестов, но не решает источник нестабильности в самих компонентах. Если у компонента нет стабильного тестового контракта, Page Object всё равно будет зависеть от внутренней вёрстки или внешних договорённостей.
Иными словами, нам нужна была compile-time автоматизация, а не ещё одно правило, которое легко забыть при разработке компонента.
Снаружи всё очень просто: разработчик пишет обычный компонент и добавляет к нему одну аннотацию.
Использование выглядит так:
Во время компиляции плагин делает две вещи:
В самом простом виде это выглядит так:
А после компиляции ключевой фрагмент modifier-цепочки фактически превращается в такой:
Важно здесь вот что:
По умолчанию тег строится из имени функции, но мы добавили и
На уровне идеи задача выглядела так:
Такой уровень контроля появляется только на уровне IR, промежуточного представления программы внутри Kotlin compiler pipeline.
На фазе IR у компилятора уже есть подробное дерево программы, но код ещё можно менять. Именно там и стало возможным внедрить наш тестовый контракт.
Отдельно было важно, что плагин у нас сразу работает с новым фронтендом: он объявляет
Если совсем без магии, то внутри это довольно прямолинейный pipeline.
Плагин проходит по функциям в IR-дереве. Если видит
Если смотреть на исходники, основные роли такие:
У
Для нас было важно поддержать оба случая. Иначе решение оказалось бы слишком хрупким и зависимым от стиля написания компонента.
Отдельно пришлось учитывать и неприятные частные случаи:
Именно для последнего случая у аннотации появился
С
С
На уровне Kotlin-кода это выглядит как аккуратная trailing lambda. Но в IR такую конструкцию приходится собирать вручную: создавать анонимную функцию, корректно выставлять receiver, находить нужные символы
Проще говоря, там, где разработчик видит несколько строк декларативного кода, плагин работает с довольно низкоуровневым деревом выражений.
Именно поэтому
После того как плагин находит
Для каждого свойства плагин строит:
На практике это выглядит так:
Это важный инженерный компромисс. Мы не пытались превратить
Компоненты дизайн-системы собираются друг из друга. Внешний компонент (
Посмотрим на реальный пример.
В
Визуально это выглядит так:
В тесте это даёт естественную навигацию. Сначала находим кнопку по экранному тегу и проверяем её параметры:
Если нужно проверить параметры подложки - ищем
Принцип прост: каждый уровень «матрёшки» - это самостоятельный
До этого мы видели автосгенерированные теги вида
Возникает вопрос: что происходит, когда плагин автоматически добавляет
Мы это предусмотрели: при наличии ручного
Это подтверждается нашими тестами:
При этом semantics от
Итого: ручной
Вот реальный state из дизайн-системы -
Здесь несколько типов свойств. Плагин для каждого подбирает свою стратегию:
Тесту достаточно проверить isEnabled = “true”, isPending = “false”, style = “Brand”.
Когда говоришь «мы кладём данные в
Во-первых, мы не подменяем существующие accessibility semantics компонента. Ручные вызовы вроде
Во-вторых, автоматически добавляемые свойства у нас носят служебный характер и читаются только тестами. Мы не используем этот механизм для пользовательских описаний, контент-лейблов или озвучиваемых значений.
В-третьих, мы явно контролируем точку встраивания и не включаем агрессивное слияние потомков. В нашем случае
Если бы наша цель была расширять accessibility-модель, а не тестовый контракт, требования к дизайну решения были бы другими.
Для нас было принципиально, чтобы эта инфраструктура не протекала в release-артефакты.
Поэтому плагин подключается только в debug-сценариях сборки, где реально запускаются UI-тесты и где тестовый контракт приносит пользу. В release-сборке этот слой просто не применяется: компонент компилируется без автоматически добавленных
Это важно по двум причинам:
На практике такую границу обязательно нужно верифицировать отдельно: проверять конфигурацию Gradle, смотреть состав debug/release артефактов и прогонять регрессионные тесты после обновления Kotlin или Compose.
Конечно, такое решение не снимает вообще все вопросы разом.
Это важный момент. Если у вас 10 composable-функций и нет общей компонентной библиотеки, compiler plugin почти наверняка будет избыточен. Цена владения у него выше, чем у обычного helper-а.
Но на масштабе дизайн-системы картина меняется. Чем больше компонентов, экранов и команд завязаны на единый тестовый контракт, тем быстрее compile-time автоматизация начинает окупаться.
Сейчас у нас уже более 150 компонентов в дизайн-системе покрыты
На практике мы получили:
Если коротко, compile-time автоматизация начала окупаться тем сильнее, чем больше становилась сама библиотека компонентов и чем больше экранов начинали от неё зависеть.
Следующий шаг, который мы хотим попробовать на базе того же подхода, это генерация Page Object.
Идея в том, чтобы использовать тот же compile-time pipeline для автоматического создания готовых тестовых обёрток вокруг компонентов. Тогда можно получить:
Если эта идея взлетит, то
В нашем случае compiler plugin оказался не способом «ещё как-нибудь навесить
Для команды это быстро стало не абстрактной инженерной задачей, а вопросом скорости и стабильности разработки. Нужно было дать тестировщикам единый способ находить компоненты на экране и проверять их состояние, не заставляя разработчиков вручную поддерживать одинаковую тестовую разметку в каждом компоненте.
Эта статья про то, как мы решили задачу через Kotlin IR Compiler Plugin. Снаружи решение выглядит почти незаметно: разработчик ставит одну аннотацию, а на этапе компиляции компонент автоматически получает стабильный
testTag и тестовые semantics, собранные из его state. В результате у команды стало меньше бойлерплейта в компонентах, меньше риска рассинхронизации между state и тестами, а экранные UI-тесты получили более устойчивый контракт работы с дизайн-системой.Проблема: верхнеуровневого testTag недостаточно
Когда мы начали разбирать вопрос «как реально тестировать Compose-экраны, собранные из наших компонентов», быстро выяснилось: просто покрыть компоненты
testTag недостаточно.Для экранного теста мало найти верхнеуровневый контейнер компонента. Нужно ещё понять, что именно компонент сейчас показывает и в каком состоянии находится. А здесь всплывала специфика нашей архитектуры: это не набор composable-функций с несколькими простыми параметрами, а библиотека компонентов со своими
state-моделями и внутренними правилами.Ключевые ограничения были такими:
Практически у каждого компонента есть собственныйstate, а не россыпь отдельных параметров у@Composable-функции.
Некоторые свойства компонента по смыслу не совпадают с «дефолтными» ожиданиями Compose. Например,isEnabledв дизайн-системе не всегда означает одинаковое визуальное поведение.
Значимые для теста признаки состояния часто распределены по внутренним типам, токенам и производным полям, а не лежат в одном плоском наборе параметров.
Сначала мы думали только про
testTag. Уже после реализации я наткнулся на vkompose от VK и поймал знакомое ощущение “параллельной эволюции”: идея очень похожая, но мы пришли к ней в рамках своей задачи.Однако в процессе обсуждения стало понятно, что вопрос на самом деле не один, а сразу несколько:
Как стабильно «достучаться» до нужного компонента в автотестах?
Как надёжно читать его значимые характеристики внутри теста?
Как тесту понять текущее состояние компонента: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) {
// ...
}
}
Во время компиляции плагин делает две вещи:
Генерирует стабильныйtestTag.
Добавляет в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
На уровне идеи задача выглядела так:
Найти функцию с@CodegenModifier.
Найти в нейmodifier.
Найтиstate.
Извлечь свойстваstate.
Построить новую цепочку модификаторов видаmodifier.testTag(...).semantics { ... }.
Подменить этой цепочкой реальные места использования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, он делает несколько последовательных шагов:
Проверяет, есть ли подходящийmodifier.
Ищет параметрstate.
Строит итоговый тег из имени функции или берётcustomTag.
Извлекает свойстваstate.
Собирает новый modifier:testTag + semantics.
Подменяет им реальные использования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, следующая задача довольно прагматичная: вытащить его свойства и сделать так, чтобы тест мог их прочитать.Для каждого свойства плагин строит:
SemanticsPropertyKeyпо имени свойства.
Вызовset(key, value)внутриsemantics.
Конвертацию значения в безопасную форму для записи.
На практике это выглядит так:
Тип свойства | Что записываем в 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.Это важно по двум причинам:
Мы не хотим тащить служебную тестовую разметку в production-артефакт без реальной пользы для пользователя.
Мы уменьшаем риск того, что тестовая инфраструктура начнёт влиять на 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-тесты на уровне экранов.