Тестуємо архітектуру за допомогою ArchUnit
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер та технічний письменник, хочу розповісти вам про бібліотеку ArchUnit, яка дозволяє писати тести для перевірки архітектурних правил та патернів. У цій статті я опишу її особливості, розповім про досвід застосування у наших проєктах, а також наведу її переваги та недоліки. Сподіваюся, що матеріал буде корисним для всіх, хто хоче більше дізнатися про сучасні технології у світі Java-індустрії.
Навіщо потрібен ArchUnit
Під час розробки програмних систем заведено писати тести для покриття коду/функціональности. Юніт-тести перевіряють конкретну реалізацію методів/класів, інтеграційні тести — взаємодію підсистем чи компонентів. Є ще performance тести, які порівнюють роботу вашого додатку під певним навантаженням із тим еталоном, який ви хочете досягти. Але при цьому я ще не бачив проєктів, де б тестувалося те, наскільки архітектура системи чи системний дизайн, а точніше їх реалізація у проєкті, відповідає загальноприйнятим чи корпоративним правилам, чи політикам. Що це можуть за перевірки:
- Розбиття вашої системи на шари чи модулі.
- Взаємодія між шарами та заборона доступу до шарів, розташованим вище (наприклад, з сервісів до контролера).
- Обмеження доступу ззовні до внутрішніх компонентів/реалізації.
Такі перевірки зазвичай робляться на code/design review вручну, але за бажання їх можна автоматизувати. Одне із найпопулярніших рішень тут — бібліотека ArchUnit. Її у 2017 році створила німецька компанія TNT (Technology Consulting GmbH) на Java, а потім портувала її на .NET.
Фактично це linter, але не для перевірки синтаксису коду, а для перевірки різних архітектурних правил і конвенцій. Його можна порівняти з іншим проєктом такого роду — SonarQube, але останній відрізняється тим, що перевіряє code smells, водночас ArchUnit дозволяє вам описувати правила у самому коді у вигляді тестів.
ArchUnit складається з трьох компонентів:
- Core API — включає завантаження байт-коду класів та доступ до них ззовні.
- Lang API — набір абстракцій над Core API, які дозволяють більш містко описувати правила, які ви хочете перевірити в тестах.
- Library API — різні готові шаблони, які ви можете використовувати у тестах «як є», без написання додаткового коду.
Як ArchUnit працює з байт-кодом? Як і інший популярний проєкт Spring Framework він не використовує Reflection API через його повільність, а застосовує бібліотеку ASM, єдиний мінус якої — її доводиться періодично оновлювати при виході нових версій JDK.
Використовуємо ArchUnit
Щоб краще зрозуміти переваги та недоліки цієї технології, додамо його до нашого проєкту в кореневому модулі:
<dependency> <groupId>com.tngtech.archunit</groupId> <artifactId>archunit</artifactId> <version>1.4.2</version> <scope>test</scope> </dependency> <dependency> <groupId>com.tngtech.archunit</groupId> <artifactId>archunit-junit5</artifactId> <version>1.4.2</version> <scope>test</scope> </dependency>
Друга залежність потрібна для використання в Junit тестах, і хоча ми вже перейшли на Junit 6, але підтримки цієї версії в ArchUnit поки що немає, але на щастя,
Візьмемо для прикладу такий клас із проєкту, який є CDI біном і одночасно реалізацією патерну Репозиторій:
@Named
public class HibernateCityRepository extends BaseHibernateRepository implements CityRepository {
Таких класів досить багато в нашому проєкті, і для них потрібно перевірити наступне:
- Вони знаходяться у пакеті repository.hibernate.
- Кожен клас містить анотацію @Named (яка оголошує його біном).
- Кожен клас реалізує інтерфейс, причому назва якого складає суфікс для даного класу.
- Також можна перевірити, що вони розширюють базовий клас BaseHibernateRepository.
Створимо тестовий клас HibernateRepositoryTest.
Загалом тести на ArchUnit можна описати в такий спосіб:
classes that ${PREDICATE} should ${CONDITION}
Тобто нам потрібно спочатку описати фільтри, якими ArchUnit шукатиме класи, а потім які умови ми хочемо перевірити. Почнемо з першого пункту.
public class HibernateRepositoryTest {
@ArchTest
static final ArchRule repository_should_be_in_repository_package = classes().that().haveNameMatching(".*Hibernate.*Repository")
.should().resideInAPackage("..repository.hibernate..")
.as("Hibernate-based repositories should reside in a package ’..repository.hibernate..’");
Відразу виділяється одна з відмінностей ArchUnit — у традиційних бібліотеках типу Junit ми оформляємо автоматизовані тести у вигляді методів, використовуючи шаблон Given/When/Then або Act/Arrange/Assert. Тут же кожен тест являє собою поле з анотацією @ArchTest.
Спочатку ми вказуємо патерн для імені класу, а потім пакет, в якому він повинен знаходитися. При цьому слід зазначити, що метод haveNameMatching() перевірятиме не саме ім’я класу, а його fully-qualified ім’я, тобто разом з усіма пакетами.
За замовчуванням ArchUnit працює з кодом, який розташований у тому ж пакеті, що і тестовий клас, тому, щоб уникнути помилок, додають анотацію @AnalyzeClasses, яка змінює базовий пакет для сканування байт-коду.:
@AnalyzeClasses(packages = «top.persistence.repository»)
public class HibernateRepositoryTest {
Тепер перевіримо, що ці класи мають анотацію @Named:
@ArchTest
static final ArchRule repository_should_be_with_named_annotation = classes().that().haveNameMatching(".*Hibernate.*Repository")
.should().beAnnotatedWith(Named.class)
.as("Hibernate-based repositories should have @Named annotation");
Запускаємо тест та отримуємо несподівану помилку:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] — Rule ’Hibernate-based repositories should have @Named annotation’ was violated (1 times): Class <top.persistence.repository.hibernate.BaseHibernateRepository> is not annotated with @Named in (BaseHibernateRepository.java:0)
Просто ArchUnit знайшов наш базовий клас, який не має анотації:
public abstract class BaseHibernateRepository {
На жаль, додати фільтр на абстрактні класи просто зробити не вийде, тому що ArchUnit не має такого правила, але можна скористатися більш складним правилом, звернувшись до байт-коду абстрактних методів, які мають модифікатор ABSTRACT (також виключивши інтерфейси). Таким чином, нам потрібно додати умову
doNotHaveModifier(JavaModifier.ABSTRACT)
В результаті отримаємо:
@ArchTest
static final ArchRule repository_should_be_with_named_annotation = classes().that()
.haveNameMatching(".*Hibernate.*Repository").and().doNotHaveModifier(JavaModifier.ABSTRACT).should()
.beAnnotatedWith(Named.class).as("Hibernate-based repositories should have @Named annotation");
Але що, якби BaseHibernateRepository не був би абстрактним класом? У такому разі довелося б додати інший фільтр — за його назвою:
static final ArchRule repository_should_be_with_named_annotation = classes().that()
.haveNameMatching(".*Hibernate.*Repository").and()
.doNotHaveSimpleName(BaseHibernateRepository.class.getSimpleName()).should().beAnnotatedWith(Named.class)
.as("Hibernate-based repositories should have @Named annotation");
Однак і ця конфігурація не є 100% правильною. Для того, щоб обвалити тест, достатньо створити такий тестовий клас:
class HibernateTestRepository {}
Щоб впоратися з цією проблемою, достатньо вказати ArchUnit, щоб він не сканував тестові класи та інші jars (які нам не цікаві):
@AnalyzeClasses(packages = "top.persistence.repository", importOptions = {
DoNotIncludeTests.class, DoNotIncludeJars.class })
public class HibernateRepositoryTest {
При цьому аналіз коду ArchUnit показує, що він підтримує Maven/Gradle, виключаючи їх тестові директорії, а також Intellij Idea:
static final PatternPredicate MAVEN_TEST_PATTERN = new PatternPredicate(".*/target/test-classes/.*");
static final PatternPredicate GRADLE_TEST_PATTERN = new PatternPredicate(".*/build/classes/([^/]+/)?test/.*");
static final PatternPredicate INTELLIJ_TEST_PATTERN = new PatternPredicate(".*/out/test/.*");
static final Predicate<Location> TEST_LOCATION = MAVEN_TEST_PATTERN.or(GRADLE_TEST_PATTERN).or(INTELLIJ_TEST_PATTERN);
static final Predicate<Location> NO_TEST_LOCATION = TEST_LOCATION.negate();
Третю умову в нашому списку перевірити складніше, тому що нам потрібно динамічно перевіряти кожен клас і ті інтерфейси, які він реалізує на збіг імен. Тому тут доведеться створити окреме поле спеціального типу DescribedPredicate, в якому ми якраз перевіряємо класи та інтерфейси на збіг імен:
static DescribedPredicate<JavaClass> hasAppropriateInterfaces = new DescribedPredicate<JavaClass>(
"implements an interface that is suffix in the current class name") {
@Override
public boolean test(JavaClass clz) {
var interfaces = clz.getInterfaces();
return interfaces.stream().anyMatch(item -> clz.getSimpleName().endsWith(item.toErasure().getSimpleName()));
}
};
Тут ми звертаємося до байт-коду нашого проекту, але при цьому, якщо придивитися до методу test(), то можна переконатися, що не використовуємо Reflection API (оскільки ArchUnit його не застосовує), а використовуючи власні абстракції цієї бібліотеки — JavaClass, JavaType та інші.
Тепер залишилося підключити це поле до наступного тесту:
@ArchTest
static final ArchRule repository_should_implement_interface = classes().that()
.haveNameMatching(".*Hibernate.*Repository").and().doNotHaveModifier(JavaModifier.ABSTRACT).should()
.beAssignableFrom(hasAppropriateInterfaces)
.as("Hibernate-based repositories should implement corresponding interface");
Четверту умову реалізувати набагато простіше, тому що ми повинні просто перевірити, що наші класи успадковують BaseHibernateRepository, а значить, ми можемо привести їх до цього типу.:
@ArchTest
static final ArchRule repository_should_extend_base_repository_class = classes().that()
.haveNameMatching(".*Hibernate.*Repository").and().doNotHaveModifier(JavaModifier.ABSTRACT).should()
.beAssignableTo(BaseHibernateRepository.class)
.as("Hibernate-based repositories should extend BaseHibernateRepository");
Розбираємо вбудовані правила
Нам вдалося написати власні правила для перевірки репозиторіїв, але, зрозуміло, розробникам цікавіше дізнатися про те, які вбудовані правила існують в ArchUnit. Якщо ви використовуєте FindBugs/SonarQube/ErrorProne та інші системи синтаксичного аналізу та перевірки коду, то найчастіше використовуєте саме вбудовану функціональність.
Почнемо з першого глобального правила — це перевірка того, чи дотримується наш проєкт багатошарової архітектури. Для нього створимо клас LayeredArchitectureTest:
@AnalyzeClasses(packages = "top")
public class LayeredArchitectureTest {
@ArchTest
static final ArchRule layer_layout_is_followed = layeredArchitecture().consideringAllDependencies()
.layer("Controllers").definedBy("top.resource..")
.layer("Services").definedBy("top.service..")
.layer("Persistence").definedBy("top.persistence..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Services");
}
Тут ми визначили три шари (Контролери, Сервіси, Репозиторії), кожен з яких знаходиться у своєму пакеті і вказали які з шарів можуть (або не можуть) взаємодіяти один з одним.
Запускаємо тест і одразу отримуємо помилку:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] — Rule ’Layered architecture considering all dependencies, consisting of where layer ’Controllers’ may not be accessed by any layer where layer ’Services’ may only be accessed by layers [’Controllers’] where layer ’Persistence’ may only be accessed by layers [’Services’]’ was violated (10 times): Method <top.binding.ComponentBinder.configure()> references class object <top.persistence.repository.CityRepository> in (ComponentBinder.java:36)
Виявляється, що ми маємо конфігураційний клас ComponentBinder, який налаштовує DI для деяких бінів:
public class ComponentBinder extends AbstractBinder {
@Override
protected void configure() {
bind(HibernateCityRepository.class).to(CityRepository.class).in(Singleton.class).qualifiedBy(new DBSourceInstance());
І оскільки він звертається до деяких шарів, то наш тест падає. Щоб виправити цю помилку, конфігурацію можна визначити як додатковий шар:
@ArchTest
static final ArchRule layer_layout_is_followed = layeredArchitecture().consideringAllDependencies()
.layer("Controllers").definedBy("top.resource..")
.layer("Services").definedBy("top.service..")
.layer("Persistence").definedBy("top.persistence..")
.layer("Binding").definedBy("top.binding..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers", "Binding")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Services", "Binding");
Але це не розв’яже проблему, оскільки сервіси, наприклад, можуть зв’язуватися з репозиторіями через додаткові компоненти. Тому загалом можна зробити простіше, використовуючи не метод consideringAllDependencies, а consideringOnlyDependenciesInLayers:
@ArchTest
static final ArchRule layer_layout_is_followed = layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("Controllers").definedBy("top.resource..")
.layer("Services").definedBy("top.service..")
.layer("Persistence").definedBy("top.persistence..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Services");
Однак, наша перевірка все ще недосконала, тому що ми змогли перевірити зв’язки між шарами, але не всередині шарів. Сервіси та репозиторії загалом можуть викликати один одного, а ось контролери — ні, вони повинні звертатися лише до сервісів. Тут нам прийде на допомогу такий елемент DSL ArchUnit, як slices, які дозволяють визначити деяку частину проєкту (коду), в цьому випадку всі REST контролери, які у нас знаходяться в пакеті resource і потім застосувати до них деяке правило, наприклад, відсутність зв’язків між ними:
@ArchTest
static final ArchRule controllers_should_only_use_their_own_slice =
slices().matching("..resource.(*)..").namingSlices("Controller $1")
.as("Controllers").should().notDependOnEachOther();
Крім того, у ArchUnit є ще три категорії вбудованих перевірок, які знаходяться у класах:
- GeneralCodingRules
- DependencyRules
- ProxyRules
GeneralCodingRules містить 8 правил, з них 6 можна використовувати практично для будь-якого сучасного проєкту:
@ArchTag("coding")
@AnalyzeClasses(packages = "top", importOptions = {
ImportOption.DoNotIncludeTests.class, ImportOption.DoNotIncludeJars.class })
public class CodingRulesTest {
Тут перевіряється, що ваш код не використовує (для логування) методи із Sys-tem.out/System.err:
@ArchTest static final ArchRule no_access_to_standard_streams = GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;
У цьому правилі перевіряється, що ви не використовуєте стандартне логування з JDK (ja-va.util.logging):
@ArchTest static final ArchRule no_use_standard_logging = GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING;
Якщо ваш додаток використовує DI, це правило перевіряє, що у вас на полях класів немає анотацій для DI (підтримуються анотації Spring/Java EE/Jakarta EE/Google Guice):
@ArchTest static final ArchRule no_field_injection = GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
Колись популярний проєкт Joda Time втратив популярність, це правило перевіряє, що ви більше не використовуєте його API:
@ArchTest static final ArchRule no_joda_time = GeneralCodingRules.NO_CLASSES_SHOULD_USE_JODATIME;
Тут відбувається перевірка на те, що ваш код не викидає стандартні виключення типу Throwable/Exception/RuntimeException. Тобто ви повинні викидати або ваші власні винятки, або спадкоємці перших трьох (наприклад, IllegalStateException або InvalidParameterException):
@ArchTest static final ArchRule no_generic_exceptions = GeneralCodingRules.NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS;
Класи Date/Time/Timestamp/Calendar моментально застаріли після виходу Java 8, проте в legacy коді вони іноді зустрічаються, і для того, щоб з цим боротися і є наступне правило:
@ArchTest static final ArchRule no_old_java_date = GeneralCodingRules.OLD_DATE_AND_TIME_CLASSES_SHOULD_NOT_BE_USED;
З останнім правилом пов’язаний цікавий момент. Уявимо, що у вас є проєкт, де багато legacy коду, у тому числі використовуються старі класи Date/Calendar. Ви не можете їх швидко замінити на бібліотеку Java Time, але при цьому не хочете, щоб у новому коді вони з’являлися. І ось тут вам допоможе така фітча ArchUnit як «заморозка» (freezing). Щоб їй користуватися, використовуйте не ArchRule, а клас FreezeArchRule:
@ArchTest static final ArchRule no_old_java_date = FreezingArchRule.freeze(GeneralCodingRules.OLD_DATE_AND_TIME_CLASSES_SHOULD_NOT_BE_USED);
Цей клас при першому запуску запам’ятовує всі порушення (violations) і зберігає в звичайному текстовому файлі. А при подальших запусках тестів перевіряється, що не з’явилися нові рядки. Запускаємо та отримуємо помилку:
com.tngtech.archunit.library.freeze.StoreInitializationFailedException: Creating new violation store is disabled (enable by configuration freeze.store.default.allowStoreCreation=true)
Наразі створення violation stores (просто місць зберігання файлів) заборонено. Створимо файл archunit.properties у папці src/test/resources:
freeze.store.default.allowStoreCreation=true freeze.store.default.path=freeze
Ми заразом вказали папку для зберігання файлів (freeze). Запускаємо тест і в цій папці з’являється новий файл з усіма порушеннями:
Method <top.service.impl.GeographicServiceImpl.saveCity(top.model.entity.City)> calls constructor <java.util.Date.<init>()> in (GeographicServiceImpl.java:46)
Тепер тести проходитимуть, але якщо ми ще десь набідокуримо і використовуватимемо Date, то тепер тест впаде:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] — Rule ’java.time API should be used, because legacy date/time APIs have been replaced since Java 8 (JSR 310)’ was violated (1 times):
Якщо ви звернули увагу, то для кожного тестового класу використовуємо анотацію @AnalyzeClasses з однаковими атрибутами:
@AnalyzeClasses(packages = "top.geography", importOptions = {
ImportOption.DoNotIncludeTests.class, ImportOption.DoNotIncludeJars.class })
На щастя, ArchUnit, починаючи з версії 1.4, підтримує метаанотації, тому ми можемо створити нову анотацію @ArchConfiguration:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@AnalyzeClasses(packages = "top.geography", importOptions = {
ImportOption.DoNotIncludeTests.class, ImportOption.DoNotIncludeJars.class})
public @interface ArchConfiguration {
}
І використовувати її у всіх тестах:
@ArchTag("coding")
@ArchConfiguration
public class CodingRulesTest {
DependencyRules містить лише одне правило, але досить примітне:
@ArchTest static final ArchRule no_classes_should_depend_upper_packages = DependencyRules.NO_CLASSES_SHOULD_DEPEND_UPPER_PACKAGES;
Воно полягає в тому, що класи не повинні використовувати (посилатися) на класи у верхніх пакетах. Наприклад, якщо у вас є пакет top, в якому розташовані інтерфейси, то ви не можете створити пакет top.impl і додати туди реалізації цих інтерфейсів. Вам потрібно покласти інтерфейси в пакет top.api. Пояснення цього правила у тому, що його дотримання дозволяє поміщати класи так, щоб це забезпечувало модульність коду і простіший процес рефакторинга.
ProxyRules також містить лише одне правило:
@ArchTest static final ArchRule no_classes_should_directly_call_transactional_methods = ProxyRules.no_classes_should_directly_call_other_methods_declared_in_the_same_class_that_are_annotated_with(Transactional.class);
Тут ArchUnit дозволяє перевірити, що наш код не викликає безпосередньо методи, які обертаються в проксі за допомогою спеціальних анотацій (наприклад, @Transactional або @Async). Тому що в цьому випадку проксування не застосовуватиметься.
Популярність
На даний момент у ArchUnit фактично немає конкурентів у своїй ніші. Він широко використовується в інших open-source проектах, таких як Gradle або Spring. І якщо проаналізувати, як він там використовується, то це перевірка на відсутність циклів (циклічних залежностей) у ваших компонентах:
ArchRule rule = SlicesRuleDefinition.slices() //
.matching("org.springframework.data.r2dbc.(**)") //
.should() //
.beFreeOfCycles();
Перевірка на те, що викликається лише перевантажений варіант toUpperCase:
static ArchRule noClassesShouldCallStringToUpperCaseWithoutLocale() {
return ArchRuleDefinition.noClasses()
.should()
.callMethod(String.class, "toUpperCase")
.because("String.toUpperCase(Locale.ROOT) should be used instead");
Або перевірка на те, що не використовуються заборонені/deprecated типи:
static ArchRule classesShouldNotImportForbiddenTypes() {
return ArchRuleDefinition.noClasses()
.should().dependOnClassesThat()
.haveFullyQualifiedName("reactor.core.support.Assert")
.orShould().dependOnClassesThat()
.haveFullyQualifiedName("org.slf4j.LoggerFactory")
.orShould().dependOnClassesThat()
.haveFullyQualifiedName("org.springframework.lang.NonNull")
.orShould().dependOnClassesThat()
.haveFullyQualifiedName("org.springframework.lang.Nullable");
У той же час на Stackoverflow у ArchUnit всього 160 питань, що говорить про те, що в базовому варіанті його досить просто використовувати, але якщо вам потрібно рішення для складніших завдань, вам швидше за все доведеться самому шукати шляхи реалізації.
Висновки
В цілому ArchUnit містить невелику кількість вбудованих правил для контролю коду/дизайну. Його головна перевага — підтримка Junit, а також гнучкий і багатофункціональний DSL, який дозволяє описати різні умови для аналізу коду. На щастя, на сайті є як детальна документація, так і велика кількість прикладів.
Із відносних мінусів бібліотека поки що не підтримує GraalVM.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів