Тестуємо архітектуру за допомогою ArchUnit

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер та технічний письменник, хочу розповісти вам про бібліотеку ArchUnit, яка дозволяє писати тести для перевірки архітектурних правил та патернів. У цій статті я опишу її особливості, розповім про досвід застосування у наших проєктах, а також наведу її переваги та недоліки. Сподіваюся, що матеріал буде корисним для всіх, хто хоче більше дізнатися про сучасні технології у світі Java-індустрії.

Навіщо потрібен ArchUnit

Під час розробки програмних систем заведено писати тести для покриття коду/функціональности. Юніт-тести перевіряють конкретну реалізацію методів/класів, інтеграційні тести — взаємодію підсистем чи компонентів. Є ще performance тести, які порівнюють роботу вашого додатку під певним навантаженням із тим еталоном, який ви хочете досягти. Але при цьому я ще не бачив проєктів, де б тестувалося те, наскільки архітектура системи чи системний дизайн, а точніше їх реалізація у проєкті, відповідає загальноприйнятим чи корпоративним правилам, чи політикам. Що це можуть за перевірки:

  • Розбиття вашої системи на шари чи модулі.
  • Взаємодія між шарами та заборона доступу до шарів, розташованим вище (наприклад, з сервісів до контролера).
  • Обмеження доступу ззовні до внутрішніх компонентів/реалізації.

Такі перевірки зазвичай робляться на code/design review вручну, але за бажання їх можна автоматизувати. Одне із найпопулярніших рішень тут — бібліотека ArchUnit. Її у 2017 році створила німецька компанія TNT (Technology Consulting GmbH) на Java, а потім портувала її на .NET.

Фактично це linter, але не для перевірки синтаксису коду, а для перевірки різних архітектурних правил і конвенцій. Його можна порівняти з іншим проєктом такого роду — SonarQube, але останній відрізняється тим, що перевіряє code smells, водночас ArchUnit дозволяє вам описувати правила у самому коді у вигляді тестів.

ArchUnit складається з трьох компонентів:

  1. Core API — включає завантаження байт-коду класів та доступ до них ззовні.
  2. Lang API — набір абстракцій над Core API, які дозволяють більш містко описувати правила, які ви хочете перевірити в тестах.
  3. 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 поки що немає, але на щастя, 5-та та 6-та версія Junit зворотно сумісні.

Візьмемо для прикладу такий клас із проєкту, який є CDI біном і одночасно реалізацією патерну Репозиторій:

@Named
public class HibernateCityRepository extends BaseHibernateRepository implements CityRepository {

Таких класів досить багато в нашому проєкті, і для них потрібно перевірити наступне:

  1. Вони знаходяться у пакеті repository.hibernate.
  2. Кожен клас містить анотацію @Named (яка оголошує його біном).
  3. Кожен клас реалізує інтерфейс, причому назва якого складає суфікс для даного класу.
  4. Також можна перевірити, що вони розширюють базовий клас 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 є ще три категорії вбудованих перевірок, які знаходяться у класах:

  1. GeneralCodingRules
  2. DependencyRules
  3. 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.

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі