Архитектурные тесты (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):
val ALL_WITH_TEST: JavaClasses = ClassFileImporter()
.withImportOption(ImportOption.DoNotIncludeJars())
.importPackages("ru.bgcrm", "ru.ufanet")
val ALL: JavaClasses = ALL_WITH_TEST.that(DescribedPredicate.not(TEST_CLASS))
val ONLY_TEST: JavaClasses = ALL_WITH_TEST.that(TEST_CLASS)
Набор импортируется один, дальше он фильтруется предикатом, а не читается заново:
-
ALL— только продуктивный код, набор по умолчанию; -
ALL_WITH_TEST— вместе с тестовыми классами; на нём проверяются правила, которые должны действовать и в тестах (ProjectImportRulesTest); -
ONLY_TEST— только тестовые классы.
Отдельная задача 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 не с чем сравнивать.
Без заморозки живут правила, у которых нарушений уже ноль: например, запрет
ru.bitel.common.function.Lazy в ProjectImportRulesTest проверяется напрямую
rule.check(…) и не терпит ни одного нового случая.
Конфигурация — в 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
Ключ в stored.rules — полный текст описания правила, то есть .as(…) вместе с
, because <текст .because(…)>. Поменял любую из двух формулировок — для 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 только в слое доступа к данным |
|
|
Слоя доступа к данным два: легаси-
|
| Правило | Смысл |
|---|---|
параметры — через |
сервис участвует в транзакции контекста, кэше и телеметрии, |
процессы — через |
то же, вместо прямого |
нет |
старый сервис помечен |
Первые два правила написаны кастомным ArchCondition и проверяют члены класса, а не
класс целиком: так они видят маркер @AllowDirectDaoAccess (см. ниже) на конкретном
методе или поле.
|
| Правило | Смысл |
|---|---|
нет |
отложенная инициализация в проекте одна — |
нет полей типа |
то же: |
нет зависимостей на |
JSON-стек один, Jackson: |
|
Правила этого класса проверяются на Запрет Gson заморожен с большим baseline (больше тысячи мест): библиотека уходит по мере
правок, а не одной сменой импортов. Помни, что |
| Правило | Смысл |
|---|---|
наследники |
клиент переносится на новый стек — интерфейс с аннотацией |
Маркер @AllowDirectDaoAccess (исключение из правил доступа к данным)
Ядро предоставляет аннотацию ru.bgcrm.architecture.AllowDirectDaoAccess — осознанное
исключение из правила «доступ к данным только через сервисы». Помеченный ею
метод/конструктор/поле может обращаться к DAO (ParamValueDAO, ProcessDAO, …) напрямую,
минуя ParameterService/ProcessService. reason обязателен — какого метода не хватает
в сервисе (пример: работа с параметром типа «файл»/«blob», которого пока нет в
ParameterService).
Маркер живёт в ядре (как @Config, @ProcessInteractorReference), чтобы им могли
пользоваться все модули (core, dyn, плагины) и их архитектурные тесты. Правило доступа
к данным есть и в ядре, и в dyn-проекте (в обоих — ServiceUsageRulesTest): его кастомное
ArchCondition пропускает элементы, помеченные этой аннотацией. Ставить её нужно
максимально узко — на конкретный элемент, а не на весь класс.
@AllowDirectDaoAccess(reason = "работа с файловыми параметрами пока не в ParameterService")
FileData loadScan(Connection con, int processId) {
return new ParamValueDAO(con).getParamFile(processId, PARAM_SCAN_ID, 0);
}
Рабочий цикл
-
Новое нарушение (например, создал
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()— запрет циклов между пакетами.