Здравствуйте! Меня зовут Владислав Донченко, я ведущий специалист по тестированию в Альфе. Хочу поделиться опытом преобразования огромного монолитного репозитория с автотестами в модульную структуру.
Если вы работаете с монорепозиторием — особенно большим — то мой рассказ покажется вам знакомым. Вы либо видите эти проблемы в своих проектах, либо уже боретесь с ними. А если ваш репозиторий пока только растёт, то со временем и ростом проекта вас ждут те же сложности, ведь это закономерный этап развития любого большого проекта.
Я буду рассказывать довольно абстрактно, без жесткой привязки к конкретным технологиям, чтобы суть наших решений можно было легко перенести на вашу технологическую базу.
Зачем бороться с монорепозиторием?
Кратко про контекст.
У нас был большой репозиторий. В нем были только тесты на API и все они лежали, грубо говоря, в одном пакете «SRC-тест», и были разбиты по API-шкам: API 1 — одни тесты, API 2 — вторые и т.д. И так все лежало в одном месте. И таких тестов было очень много — больше 8000 на 2024 год. То же самое касается сущностей. Всех DTO-шек. всех менеджеров, сервисов, HTTP-клиентов, также было очень много.
А сборщик их видел как единую структуру. Допустим, чтобы запустить хотя бы один тест, ему нужно было подкачать все зависимости, проанализировать и только после этого он запускал тесты.
Добавим к этому, что в огромный монорепозиторий с API-тестами льют свой код около сотни команд.
С какими же основными проблемами мы столкнулись?
№1. Долгий запуск тестов
Под долгим запуском тестов я имею ввиду сборку проекта.
Что в нашем понимании долгий? Представим, что вы пишете тесты. Сколько раз вам нужно их запустить для отладки? Если вы опытный специалист, то за 2-3 подхода справитесь. Да даже 5-6 перезапусков для подготовки тестовых данных и прохождения теста — результат лучше, чем у многих.
И вот представьте, что каждый такой перезапуск после небольшой правки отнимает от 2 до 5 минут, в зависимости от того насколько мощный у вас комп. Звучит больно да? Думаю, что вы понимаете суть проблемы.
И я не могу сказать что это мы какие-то уникальные и довели наш проект до такого состояния — это закономерный этап развития любого большого проекта.
№2. Одна ошибка — все сломалось
Работать монорепозиторием подобного размера это как танцевать вальс на минном поле — одна ошибка в классе, интерфейсе, тесте, да где угодно, приводит либо к неожиданным результатам, либо вообще ничего не собирается. Помним о том что код в репозиторий льет около сотни команд, приправим бесконечным потоком на ревью, и а соблазн просто поставить апрув растет пропорционально количеству запросов на ревью.
И вот мы получаем сложный проект со стабильно высокими рисками сломаться и заблочить поставки в прод имея на руке козырь в виде запуска около 2-5 минут.
В итоге в минусе у нас время, силы и ментальное здоровье, а кому это надо? Никому.
№3. Рефакторинг Core-части на грани невозможного
Здесь я приведу небольшой пример, чтобы было понятно, о чём я говорю. Допустим, у нас есть простая задачка — улучшить логирование в TestOps. Она кажется идеальной для junior'a: и экспертизу развивает, и проект улучшает.
Но на практике новичок сталкивается со стеной: разобраться в гигантском проекте сходу невозможно. А каждое изменение требует десятка экспериментов. Помните наше время сборки? Умножаем 5 минут на 10 попыток и понимаем, что проще было сделать самому, чем объяснять полдня. Развитие проекта в таких условиях уверенно заходит в тупик
Осознав эти проблемы мы сформулировали для себя четкие задачи.
Задачи
Я немножко говорю о нашем стеке. Наш репозиторий написан на Java и собирается сборщиком Gradle. Это почти вся техническая информация, которая будет у меня в статье. Итак, давайте по задачам.
Быстрая сборка при запуске тестов
Первое, что мы решили исправить — так это нашу сборку. Логика проста: чем больше в проекте сущностей, тем дольше компиляция. Но ведь запускаемые тесты никогда не требуют всех зависимостей проекта сразу!
Возникает резонный вопрос: «А зачем нам собирать весь проект, когда нам нужна лишь его малая часть для тестов?». Ответ на этот вопрос и стал нашей главной целью.
Удобство пользования
Насчёт удобства пользования тоже всё довольно просто — в большом репозитории тяжело ориентироваться. У нас большая и сложная архитектура, большое дерево проекта, с которым просто визуально тяжело работать.
Все вы, наверное, видели большие проекты, а в них лесенки из пакетов, длиннющие, которые сложно открывать, закрывать и рыться? Вот это огромное дерево файлов, конечно, утомляет. Это не только замедляет работу, но и просто портит всё удовольствие от процесса. Мы хотели сделать работу с репозиторием для наших тестировщиков хотя бы приятной. Но отмечу, что полностью эту задачу в гигантском репозитории решить будет невозможно.
Независимость тестов
В идеальном мире все тесты у нас должны быть атомарными, маленькими, проверяющими какой-то конкретный функционал и должны зависеть только от Core-части.
Но из-за очевидных проблем монорепозитория не всегда такое правило удаётся соблюдать на практике. Поэтому здесь мы решили не ограничивать тестировщика какими-нибудь дополнительными инструкциями, какими-нибудь дополнительными проверками на ревью, а перенести возможность имплементации зависимости тестами с уровня классов на уровень модулей.
И, казалось бы, эти три задачи решают наши проблемы в корне. Но мы помним, что у нас гигантский репозиторий, код в него везёт порядка сотни команд, за день висит порядка 20 PR. И сделать просто какую-нибудь веточку и его преобразовать даже двумя, тремя тестировщиками довольно проблематично.
У нас будет куча конфликтов, нам вообще нужно остановить работу всех команд над этим репозиторием на время работ, сами представляете какого размера будет PR, если у нас репозитория условно имеет порядка 10-20 тысяч классов. Соответственно, этот PR будет долго, во-первых, проверяться, а во-вторых, нести за собой такие большие серьезные риски, все сломать. Риски остановить все поставки слишком высоки.
Поэтому к решению задачи мы подходили очень аккуратно. Нам был нужен осторожный, поэтапный план, который поручили небольшой группе опытных энтузиастов. И вот давайте теперь я наконец-таки расскажу, что же мы делали. Начнем с самой интересной части — с преобразования проекта.
Ускорение сборки
Здесь разделили работу на два ключевых этапа.
Первый этап: декомпозиция Core-части
Мы решили, что будем разделяться на модули: один проект будет содержать много модулей с тестами. Чтобы такое провернуть, мы начали с анализа наших зависимостей, с рефакторинга нашего конфигурационного файла сборщика, или как мы его называем — build.gradle: вынесли в отдельные блоки именно те таски и зависимости этого сборщика, которые точно понадобятся всем модулям.
Затем стали нарезать наш монолит на отдельные модули, разделенные по функциональному признаку:
сначала вынесли общие классы,
потом все DTO,
затем HTTP-клиент,
после — утильные классы.
Все сущности выносили отдельно друг от друга, например, в один модуль вынесли DTO, в другой — HTTP-клиент. К ним же перенесли все зависимости и подключили модули друг к другу в определенной иерархии и к проекту, чтобы сохранить обратную совместимость (чтобы всё работало так, как работало).
Что это дало? Теперь сборщику не нужно было каждый раз компилировать гору лишнего кода. Вместо этого он использовал уже готовые, скомпилированные бинарники этих модулей.
Распутывать наши core сущности — самая сложная работа (не считая того, что она довольно рутинная) во всем проекте. Чтобы вынести HTTP-клиент нужно было сначала вынести DTO, а чтобы вынести DTO мы должны перетянуть за раз 10 пакетов, а эти 10 пакетов тянут за собой ещё 20 других пакетов. И вот такой клубок довольно не просто распутать.
Что примечательно, результат такой непростой работы не принёс нам какого-то кардинального ускорения сборки. Хотя мы и получили другой важный бонус — хорошенько оптимизировали работу с кэшем. Теперь при изменении, скажем, HTTP-клиента, модули с DTO и утилитами не пересобирались. А вот уже на этом мы экономим довольно много времени.
Второй этап: создание модулей с автотестами
После того как Core был систематизирован и разложен по полочкам, мы стали пробовать переносить сами автотесты в отдельные модули.
Первая часть работы выглядела довольно сложной рутиной и мы выполняли ее закрытой командой. Вторую же часть работы пропилотировали автотестовой командой специалистов и отдали на откуп тестировщикам.
Ведь, по сути, работа переноса автотестов довольно проста.
создаем модуль,
наполняем build.gradle какими-нибудь типовыми зависимостями (Ctrl-C, Ctrl-V),
и тесты из проекта переносим в этот модуль (drag-and-drop).
Знания Java или сборщика не нужны. С этой работой справится даже стажёр, который автотесты писал только на каких-то курсах.
При этом стоит помнить, что теперь у нас тестовые модули довольно маленького размера — они содержат условные 5-10 классов, которые компилируются гораздо быстрее, чем весь проект. При этом они к себе имплементируют тоже не все зависимости, которые есть, а только те, которые им нужны. Вместо сборки всего проекта на 5 минут, мы собираем маленький модуль, где, скажем, 10-20 тестов на API-шку и всего 3 необходимые зависимости.
Время сборки сократилось в разы.
Дополнительный бонус — удобство: все тесты, ожидаемые результаты, настройки и файлы для конкретных тестов, тот же build.grade, лежат рядом, в одном месте. Больше не нужно рыться в гигантском дереве пакетов!
Унификация и борьба с легаси
Казалось бы, мы добились своего.
Но к нашим маленьким, быстрым и удобным тестовым модулям подключены большие, мощные и тяжелые модули с Legacy. И у них те же самые проблемы, что и у нашего большого репозитория: те же самые риски, та же самая скорость первой сборки проекта, тот же HTTP-клиент, который обрастал функциональностью годами.
Чтобы добиться максимального ускорения и решить проблему рефакторинга легаси, мы применили (новый) паттерн проектирования «Адаптер».

Давайте объясню на простом примере. Допустим, у нас есть модуль автотестов, который использует большой легаси-модуль с HTTP-клиентом. Работа с этим модулем сохраняет все риски, что были озвучены до этого. Чтобы отвязать тесты от этого легаси мы создали маленькие модули-адаптеры, в зависимостях которых используется отдельная библиотека HTTP-клиента.
Мы не трогаем тесты, но между ними и легаси мы создаем новый, маленький модуль-адаптер. Этот адаптер имеет одну зависимость — ту самую легаси-библиотеку, и реализует простой контракт-прослойку. То есть модуль к себе имплементирует одну библиотеку и содержит внутри себя буквально 3-5 классов, которые реализуют контракт, связывающий эту библиотеку и наши тесты.
Такие маленькие адаптеры собираются мгновенно и дают нам ту самую желанную гибкость и скорость. Ведь скомпилировать тестовый модуль на 5-10 классов и скомпилировать какой-нибудь один модуль-адаптер на 5 классов будет в разы быстрее, чем собирать legacy модули, верно?
Благодаря модулям мы получили ещё один серьезный бонус. Допустим, что через некоторое время мы хотим заменить наш старый HTTP-клиент на новый. Раньше это была бы мучительная задача по рефакторингу сотен тестов. Теперь же мы меняем зависимость и логику только в одном месте — в модуле-адаптере. Сами автотесты даже «не узнают», что что-то поменялось. Мы изолировали изменения!

Управление зависимостями
Окей, мы разделили проект и тут возникает закономерный вопрос: как теперь эффективно управлять зависимостями?
У нас был такой подход: в конфигурационном файле сборщика находился блок dependency, в котором прописаны все зависимости, требуемые всем тестам. В принципе, можно было оставить как есть и всё бы работало. Но если оставить всё как было — в одном build.gradle, — то все зависимости будут подтягиваться во все модули, а разбрасывать версии библиотек по каждому build.gradle — это кошмар для поддержки.
Мы начали искать решение и быстро нашли его в самом Gradle — Version Catalog, или TOML-файл. Оно не было чем-то революционным, но идеально решало нашу задачу.
Мы создали один центральный файл, где прописали все наши библиотеки и их версии, присвоив их переменным. А в build.gradle каждого модуля стали просто ссылаться на необходимые переменные.

В итоге мы сохранили централизованное управление зависимостями — всё находится в одном файле, рядом, всё довольно удобно. Но при этом все эти зависимости не подтягиваются при сборке какого-то конкретного маленького модуля.
Процесс не стал сложнее — просто перенесли его в более подходящий для модульной архитектуры формат. Это было простое и, как показала практика, очень правильное решение для нашей новой реальности
Результаты
Итак, давайте вернемся к тем задачам, которые мы ставили в начале, и посмотрим, что у нас получилось.
Нашу главную боль — долгую сборку — мы решили разделением монолита на модули. Теперь мы компилируем только то, что нужно для конкретных тестов, а не весь проект целиком. Как я уже говорил, у нас получилось два паттерна: один с Legacy, один с адаптерами.
Если мы хотим переезжать тестами в модули с Legacy, мы просто создаем модуль, драг-энд-дропом перетаскиваем и почти все готово.
При подходе с адаптерами придется, конечно, рефакторить тесты, но зато мы сможем получить максимальный буст по скорости. Также у нас при усложнении архитектуры проекта нет изменений в практике управления зависимостями: всё управляется также из одного места, также удобно и, в принципе, всё нас устраивает.
Унификации инструментов и подходов добились с помощью нового паттерна-адаптера. Теперь у нас есть не очень много небольших простеньких контрактов, которые довольно легко поддерживать, довольно легко распространять на все тесты.
Проблему «одна ошибка — все сломалось» победили за счет изоляции. Падение в одном модуле больше не блокирует всю систему.
Сделать работу удобнее помогли те самые модули с автотестами, где все related файлы лежат рядом, и Version Catalog, который сохранил централизованное управление зависимостями.
А для обеспечения гибкости на будущее мы внедрили адаптеры. Теперь замена ключевых библиотек перестала быть болью.
А вот мои любимые цифры:
Время запуска тестов на Jenkins уменьшилось в 3 раза, потому что скачиваем только те зависимости, и те части проекта, что нам требуются.
Время перезапуска тестов с кэшем уменьшилось в 25 раз: со 150 до 6 с. Здесь мы получили максимальный буст. Приведу пример: у меня были тесты на довольно сложной API, и каждый раз, когда мы затрагивали эти тесты, правили, даже какую-нибудь ерунду, я каждый раз ждал, что они соберутся за 2,5 минуты. А теперь в отдельном модуле такие тесты у меня локально собираются всего за 6 секунд.
Но стоит отметить, что размер проекта все равно остался довольно большим. И если нам нужно проект собирать целиком, это всё равно будет занимать какое-то время.
Я хочу отметить точки роста, которые мы увидели, пока проводили эту работу.
Первое — хочется полностью уйти от легаси, конечно, внедрить паттерн адаптера до самого конца и использовать только его. Но понятно, что эта работа довольно большая, требует рефакторинга абсолютно всех тестов, и она у нас только запланирована.
Второе. Оптимизацию пайплайнов в CICD можно вообще не трогать и все будет работать так же, как и работает. В принципе, мы так и поступили. Но, хотя, можно и оптимизировать, например, настраивать стейджи Jenkins, чтобы не было лишних сборок, лишних скачиваний ненужных библиотек.
Третье. Сейчас у нас на PR полность собирается весь проект, чтобы прогнать чек-стайл. В принципе, можно все оставить как есть, и все будет также работать. Но такая работа у нас выглядит неоптимально и время сборки при автоматизированных проверках тоже можно уменьшать.
Лирическое отступление
Главная цель «реформ» — достижение максимального эффекта с минимальными изменениями (трудозатратами). Сложно большому количеству команд резко сказать, что наши правила изменились, теперь мы работаем иначе. Мы хотели сохранить ту практику работы с репозиториями, которая у нас уже была.
Поэтому мы решили подходить именно со стороны нашего сборщика — со стороны Gradle, чтобы не производить радикальных изменений с проектом. В принципе наши тесты сейчас в модулях даже они выглядят точно так же как и выглядели до этого они вообще никак не меняются.
Поэтому только благодаря нашей идее всё сделать с наименьшими трудозатратами, без радикальных изменений, мы выбрали такой подход.
Этот подход хорош тем, что его можно разбить на маленькие ступени, выполнять поочередно и каждая маленькая ступенька будет приносить какие-то результаты. Хотя он и достаточно трудозатратный
Этот путь занял время и требовал осторожного внедрения, но в результате мы получили систему, которая не просто решает текущие проблемы, а заложила основу для будущего развития.
Мы снова получаем удовольствие от работы с нашими автотестами. И я надеюсь, что наш опыт поможет и вам, когда вы столкнетесь с похожими вызовами.
Телеграм-канал Alfa Digital, где рассказывают о работе в IT и Digital: новости, события, вакансии, полезные советы и мемы.
lemonke
ukolchik sdelaem
ukolchik sdelaem
ukolchik sdelaem
ukolchik sdelaem
durka deports from minecraft server