Архитектурные тесты (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, а НЕ аннотационная интеграция
|
Импорт классов
Импорт байткода — дорогая операция, поэтому выполняется один раз и переиспользуется
всеми тестами (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/.
| Правило | Смысл |
|---|---|
нет доступа к |
вывод только через |
нет generic-исключений |
бросаем |
нет |
логирование через log4j2 / проектный |
нет прямого |
единый логгер |
поля |
логгер не на экземпляр и не переприсваивается |
нет |
асинхронность через |
нет |
то же: |
нет |
соединения из пула, через |
Сам AsyncWorker лежит в baseline правил про Executors и Thread — он и есть
штатная точка создания пула, это не недосмотр.
|
| Правило | Пакет / условие |
|---|---|
наследники |
суффикс |
|
|
|
|
реализации |
|
|
|
|
|
|
|
|
|
|
|
|
|
| Правило | Контракт |
|---|---|
|
реализуют |
классы с |
реализуют |
| Правило | Смысл |
|---|---|
|
не зависит от web-слоя ( |
|
не зависит от |
DAO не зависят друг от друга |
смежные данные собирает вызывающий слой; наследование базовых DAO не считается |
| Правило | Смысл |
|---|---|
SQL только в слое доступа к данным |
|
|
Слоя доступа к данным два: легаси-
|
Рабочий цикл
-
Новое нарушение (например, создал
FooDAOвне..dao..) → архитектурный тест падает со списком классов-нарушителей. Исправляешь размещение — зелёно. -
Почистил старьё → нарушение исчезает из baseline автоматически (
allowStoreUpdate=true). -
Готов сделать правило абсолютно строгим → убираешь у него обёртку
freeze(…); теперь правило не терпит ни одного нарушения.
Как добавить своё правило
-
Открой подходящий тест (или создай новый в пакете
ru.bgcrm.architecture). -
Опиши правило fluent-API и проверь его на общих классах
ProjectClasses.ALL. Оберни вfreeze(…), если в legacy возможны исключения. -
Напиши javadoc: зачем правило.
-
Прогони и посмотри на число нарушений: ноль у нового правила почти наверняка значит, что предикат не работает (см. «Правило без совпадений проходит молча»).
-
Закоммить тест вместе с 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()— запрет циклов между пакетами.