惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Security Archives - TechRepublic
Security Archives - TechRepublic
I
InfoQ
阮一峰的网络日志
阮一峰的网络日志
云风的 BLOG
云风的 BLOG
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
AWS News Blog
AWS News Blog
S
SegmentFault 最新的问题
T
Tailwind CSS Blog
The Hacker News
The Hacker News
GbyAI
GbyAI
P
Palo Alto Networks Blog
博客园 - 三生石上(FineUI控件)
Y
Y Combinator Blog
Stack Overflow Blog
Stack Overflow Blog
博客园 - Franky
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Cyberwarzone
Cyberwarzone
H
Help Net Security
S
Securelist
月光博客
月光博客
博客园 - 【当耐特】
T
Threatpost
T
Tenable Blog
G
GRAHAM CLULEY
博客园 - 司徒正美
I
Intezer
MyScale Blog
MyScale Blog
T
Threat Research - Cisco Blogs
P
Privacy & Cybersecurity Law Blog
The GitHub Blog
The GitHub Blog
C
CERT Recently Published Vulnerability Notes
T
Tor Project blog
Google DeepMind News
Google DeepMind News
C
Cybersecurity and Infrastructure Security Agency CISA
罗磊的独立博客
腾讯CDC
P
Privacy International News Feed
博客园_首页
The Cloudflare Blog
Cisco Talos Blog
Cisco Talos Blog
A
About on SuperTechFans
V
Vulnerabilities – Threatpost
A
Arctic Wolf
B
Blog RSS Feed
Recorded Future
Recorded Future
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Google DeepMind News
Google DeepMind News
S
Security Affairs
Microsoft Security Blog
Microsoft Security Blog
L
LangChain Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Kotlin IR Compiler Plugin в дизайн-системе: автотесты с Compose без ручной разметки
MaxTox3 (БКС · 2026-04-29 · via Все публикации подряд на Хабре

Меня зовут Максим, я 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 от 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-тесты на уровне экранов.