Архитектурные тесты (ArchUnit)

Назначение

ArchUnit проверяет архитектурные соглашения проекта как обычные unit-тесты: читает скомпилированные классы (байткод), строит граф зависимостей и валидирует правила (размещение классов по пакетам, именование, слои, запрещённые зависимости и т.д.). Цель — не дать архитектуре «расползаться»: новый *DAO вне ..dao.., прямой вызов System.out, использование стороннего логгера и т.п. будут ронять сборку.

Как это устроено в bgcrm

Зависимость

Библиотека подключена для всех модулей в корневом build.gradle.kts (общий тест-блок):

// Архитектурные тесты (ArchUnit, core API — совместимо с JUnit4-раннером core)
testImplementation("com.tngtech.archunit:archunit:1.3.0")

Используется core API ArchUnit + JUnit 4, а НЕ аннотационная интеграция archunit-junit5. Причина: модуль core запускает тесты дефолтным JUnit4-раннером (без useJUnitPlatform()), поэтому @AnalyzeClasses/@ArchTest там бы не выполнились. Правила пишутся как обычные @Test-методы с rule.check(…​).

Импорт классов

Импорт байткода — дорогая операция, поэтому выполняется один раз и переиспользуется всеми тестами (ru.bgcrm.architecture.ProjectClasses):

static final JavaClasses ALL = new ClassFileImporter()
        .withImportOption(new ImportOption.DoNotIncludeTests())
        .importPackages("ru.bgcrm", "ru.ufanet");

Отдельная задача archTest

Импорт всего графа классов не влезает в дефолтные 512 МБ тест-форка, поэтому архитектурные тесты вынесены в свою задачу с увеличенной кучей (core/build.gradle.kts). Она навешена на test через finalizedBy, то есть в CI выполняется автоматически.

./gradlew :core:archTest                                   # все правила
./gradlew :core:archTest --tests "*PersistenceRulesTest*"  # одно

Заморозка нарушений (freeze) — ключевой приём для legacy

Проект большой и не «стерильный», поэтому строгие правила сразу «покраснели» бы на сотнях мест. Все правила обёрнуты в FreezingArchRule.freeze(…​):

  • при первом запуске ArchUnit записывает baseline текущих нарушений в каталог archunit_store;

  • далее тест падает только на НОВЫХ нарушениях;

  • когда старое нарушение исправлено — оно уходит и из baseline (store «худеет»).

Каталог core/archunit_store коммитится — без него CI не с чем сравнивать.

Конфигурация — в core/src/test/resources/archunit.properties:

freeze.store.default.allowStoreCreation=true   # создать baseline при первом запуске
freeze.store.default.path=archunit_store       # относительно рабочего каталога модуля
freeze.store.default.allowStoreUpdate=true     # исправленные нарушения уходят из baseline
freeze.refreeze=false                          # НЕ дописывать новые нарушения молча

resolveMissingDependenciesFromClassPath=false  # не тянуть внешние типы в импорт (память)
archRule.failOnEmptyShould=false               # правило без совпадений — не ошибка

Грабли

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

Baseline записывается по тому коду, который видел ты

Заморозка сравнивает нарушения с baseline. Если правило добавлено на ветке, отведённой давно, а CI проверяет develop, где кода больше, — CI увидит нарушения, которых в baseline нет, и упадёт. Это не ошибка правила, это рассинхрон.

Влей целевую ветку до прогона, записывающего baseline, и только потом коммить archunit_store.

Описание правила — это его ключ в store

Baseline привязан к тексту из .as(…​). Поменял формулировку — для ArchUnit это новое правило: старый baseline осиротеет (файл останется в каталоге навсегда), новый запишется с нуля. Если смысл правила не изменился — не трогай описание.

Как перезаписать baseline

Штатного «дозаписать новое нарушение» нет: в этом и смысл заморозки. Есть два пути:

  • удалить строку правила в archunit_store/stored.rules и файл с его UUID, затем прогнать задачу — правило станет «новым» и запишет baseline заново;

  • временно выставить freeze.refreeze=true в archunit.properties, прогнать, вернуть обратно.

Задача archTest не пробрасывает системные свойства в тестовую JVM, поэтому -Darchunit.freeze.refreeze=true из командной строки не сработает — только правка файла.

Внешние типы не разрешаются

При resolveMissingDependenciesFromClassPath=false типы вне ru.bgcrm/ru.ufanet (java.sql., javax.servlet.) попадают в граф заглушками без иерархии. Следствие:

  • проверки по имени (haveFullyQualifiedName, resideInAPackage, сравнение getFullName()) работают;

  • проверки по иерархии (areAssignableTo, implement) на внешних типах молча дают ноль совпадений.

Для внутренних типов (ru.bgcrm.*) иерархия разрешается, assignableTo работает.

Правило без совпадений проходит молча

archRule.failOnEmptyShould=false (нужен из-за предыдущего пункта) означает, что сломанный предикат не роняет тест: правило просто ничего не находит, тест зеленеет, а baseline обнуляется. Проверяй новое правило по числу нарушений, а не по цвету теста.

Kotlin inline подставляет тело в вызывающего

ErpRepository.execute/executeUpdate объявлены inline. Kotlin вставляет их байткод в вызывающий класс, поэтому каждый репозиторий выглядит для ArchUnit прямым пользователем PreparedStatement/ResultSet, даже если в исходнике JDBC не упоминает.

Наследование выглядит как зависимость

super()-вызов базового класса — такая же зависимость, как обращение к чужому классу. Готовый dependOnClassesThat() их не различает: он видит только класс-цель и ничего не знает об источнике. Если различие важно — пиши свой ArchCondition, там доступны обе стороны (пример — LayerDependencyRulesTest.dependOnOtherDao).

Каталог правил

Тесты лежат в core/src/test/java/ru/bgcrm/architecture/.

Общая гигиена кода — GeneralCodingRulesTest
Правило Смысл

нет доступа к System.out/err

вывод только через ru.bgcrm.logging.Logger

нет generic-исключений

бросаем BGException/BGMessageException, а не Exception/RuntimeException

нет java.util.logging

логирование через log4j2 / проектный Logger

нет прямого org.slf4j

единый логгер ru.bgcrm.logging.Logger

поля Loggerprivate static final

логгер не на экземпляр и не переприсваивается

нет Executors

асинхронность через AsyncWorker.submit (контекст, транзакция, телеметрия)

нет Thread напрямую

то же: AsyncWorker

нет DriverManager

соединения из пула, через ConnectionManager

Сам AsyncWorker лежит в baseline правил про Executors и Thread — он и есть штатная точка создания пула, это не недосмотр.
Именование и размещение — NamingConventionsTest
Правило Пакет / условие

наследники BaseAction

суффикс *Action и пакет ..struts.action..

*DAO

..dao..

*Cache

..cache..

реализации javax.servlet.Filter

..servlet.filter..

*Command (CQRS)

..command..

*Query (CQRS)

..query..

*Repository

..repository..

*Listener

..listener.. или ..listeners..

*ServiceImpl

..impl..

*Facade

..usecase.. (плагин-стандарт)

Типовая принадлежность — TypeHierarchyRulesTest
Правило Контракт

*Event

реализуют ru.bgcrm.event.Event

классы с @Config

реализуют ru.bgcrm.util.configuration.Configurable

Зависимости между слоями — LayerDependencyRulesTest
Правило Смысл

ru.bgcrm.model..

не зависит от web-слоя (struts/servlet)

..dao..

не зависит от struts

DAO не зависят друг от друга

смежные данные собирает вызывающий слой; наследование базовых DAO не считается

Работа с БД — PersistenceRulesTest
Правило Смысл

SQL только в слое доступа к данным

Statement/PreparedStatement/CallableStatement/ResultSet допустимы в ..dao.. и ..repository..

Слоя доступа к данным два: легаси-*DAO в ..dao.. и новый слой — наследники ErpRepository в ..repository.. (пример: DefaultProcessTaskRepository). Новый код пишется в репозиториях.

java.sql.Connection в правило намеренно не входит: соединение пробрасывается параметром через action’ы, сервисы и слушатели, запрет на него означал бы запрет почти на весь проект.

Рабочий цикл

  • Новое нарушение (например, создал FooDAO вне ..dao..) → архитектурный тест падает со списком классов-нарушителей. Исправляешь размещение — зелёно.

  • Почистил старьё → нарушение исчезает из baseline автоматически (allowStoreUpdate=true).

  • Готов сделать правило абсолютно строгим → убираешь у него обёртку freeze(…​); теперь правило не терпит ни одного нарушения.

Как добавить своё правило

  1. Открой подходящий тест (или создай новый в пакете ru.bgcrm.architecture).

  2. Опиши правило fluent-API и проверь его на общих классах ProjectClasses.ALL. Оберни в freeze(…​), если в legacy возможны исключения.

  3. Напиши javadoc: зачем правило.

  4. Прогони и посмотри на число нарушений: ноль у нового правила почти наверняка значит, что предикат не работает (см. «Правило без совпадений проходит молча»).

  5. Закоммить тест вместе с baseline из core/archunit_store.

@Test
public void mappers_should_reside_in_mapper_package() {
    ArchRule rule = classes()
            .that().haveSimpleNameEndingWith("Mapper")
            .should().resideInAPackage("..mapper..")
            .as("Классы *Mapper должны находиться в ..mapper..");
    freeze(rule).check(ProjectClasses.ALL);
}

Полезные конструкции:

  • classes().that()…​should()…​ — правила по классам;

  • noClasses().that()…​should().dependOnClassesThat()…​ — запрет зависимостей;

  • noClasses().that()…​should(customCondition()) — когда нужен доступ к обеим сторонам зависимости;

  • fields().that().haveRawType(…​)…​should()…​ — правила по полям;

  • slices().matching("ru.bgcrm.(*)..").should().beFreeOfCycles() — запрет циклов между пакетами.