기타

ArchUnit을 이용한 패키지 의존성 테스트하기

v0o0v 2022. 3. 30. 17:59

ArchUnit을 이용한 패키지 의존성 테스트하기

최근 클린아키텍처 스터디를 하면서 헥사코날 아키텍처를 테스트 하는 것에 대한 논의가 있었다.

여러 논의가 오갔고 이상적인 것은 테스트를 자동화하는 것이었다.

책에 나온 ArchUnit 으로 해당 테스트를 자동화 하는 것을 찾아보았다.

ArchUnit

https://www.archunit.org/

ArchUnit is a free, simple and extensible library for checking the architecture of your Java code using any plain Java unit test framework.

  • Maven & JUnit5

    <dependency>
      <groupId>com.tngtech.archunit</groupId>
      <artifactId>archunit-junit5</artifactId>
      <version>0.23.1</version>
      <scope>test</scope>
    </dependency>
  • Gradle & JUnit5

    dependencies {
      testImplementation 'com.tngtech.archunit:archunit-junit5:0.23.1'
    }
  • Test Code 기본적인 구조

    public class MyArchitectureTest {
      @Test
      public void some_architecture_rule() {
          JavaClasses importedClasses = new ClassFileImporter().importPackages("com.myapp");
    
          ArchRule rule = classes()... // see next section
    
          rule.check(importedClasses);
      }
    }

결국 rule을 잘 정의해주는 것이 전부라고 할 수 있다.

그럼 실제 예제를 보면서 진행을 해보자.

https://github.com/v0o0v/archunitTest

  • layers
    • controller layer
    • service layer
    • persistence layer
  • Dependency
    • controller -> service
    • persistnce -> service

Test1 : controller / persistence 패키지는 service 패키지에 의존성을 갖는다

@Test
void controller패키지는service패키지에만의존성을갖는다() {

    JavaClasses jc = new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("com.example");

    ArchRule rule = classes()
            .that().resideInAnyPackage("..controller..")
            .should().onlyDependOnClassesThat().resideInAnyPackage("..service..")
            ;

    rule.check(jc);
}

결과는?

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in any package ['..controller..'] should only depend on classes that reside in any package ['..service..']' was violated (3 times):
        Class <com.example.archunittest.controller.HelloController> extends class <java.lang.Object> in (HelloController.java:0)
        Constructor <com.example.archunittest.controller.HelloController.<init>()> calls constructor <java.lang.Object.<init>()> in (HelloController.java:6)
        Field <com.example.archunittest.controller.HelloController.helloService> is annotated with <org.springframework.beans.factory.annotation.Autowired> in (HelloController.java:0)

3가지나 위반했다고 나온다.

  1. Object class를 상속
  2. 기본 생성자가 Object 클래스의 생성자를 콜
  3. 스프링의 @Autowired를 사용

사실 우리는 너무나 당연하게 사용했던 것들이 묵시적으로 아키텍처 규칙을 위반했던것이다.

그래도 이정도는 사실 문제라고 할 수 없기 때문에 pass로 취급해줘야한다.

코드를 수정해보자

@Test
void controller패키지는service패키지에만의존성을갖는다2() {

    JavaClasses jc = new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("com.example");

    ArchRule rule = classes()
            .that().resideInAnyPackage("..controller..")
            .should().onlyDependOnClassesThat().resideInAnyPackage("..service..","java..", "javax..", "org.springframework..")
            ;

    rule.check(jc);
}

결과는 당연히 패스가 된다.

하지만, 이게 최선일까?

생각을 좀 바꿔서 service 레이어 입장에서 의존 가능한 부분을 정의해보자.

@Test
    void service패키지는controller와persistence패키지에서만의존성을갖는다() {

        JavaClasses jc = new ClassFileImporter()
                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
                .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
                .importPackages("com.example");

        ArchRule rule = classes()
                .that().resideInAnyPackage("..service..")
                .should().onlyHaveDependentClassesThat().resideInAnyPackage("..controller..","..persistence..","..service..")
                ;

        rule.check(jc);
    }

Test2 : controller 패키지는 persistence 패키지에 의존할 수 없다.

Test1은 allow-based 방식이라면 Test2는 deny-based 방식으로 하면 안되는 부분을 테스트한다.

controller는 persistence에 의존하지 말아야한다를 추가해보자.

@Test
void controller패키지는persistence패키지에의존성을갖지않는다() {

    JavaClasses jc = new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("com.example");

    ArchRule rule = noClasses()
            .that().resideInAnyPackage("..controller..")
            .should().onlyDependOnClassesThat().resideInAnyPackage("..persistence..")
            ;

    rule.check(jc);
}

이렇게 deny-based 방식을 쓸수도 있다.

Test3 : Layered Architecture Test

물론 이런 방식으로도 테스트가 어느정도 가능하지만 뭔가 좀 더 아키텍처에 맞는 추상화 테스트가 필요해보인다.

ArchUnit은 Library API를 제공해서 아키텍처 테스트에 대한 추상화를 제공한다.

@Test
void layerTest() {

    JavaClasses jc = new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_ARCHIVES)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
            .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
            .importPackages("com.example");

    Architectures.LayeredArchitecture arch = layeredArchitecture()
            // Define layers
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Persistence").definedBy("..persistence..")
            // Add constraints
            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Persistence").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller","Persistence")
            ;

    arch.check(jc);
}

참고로 아래와 같이 hexagonal 아키텍처도 테스트 가능

onionArchitecture()
        .domainModels("com.myapp.domain.model..")
        .domainServices("com.myapp.domain.service..")
        .applicationServices("com.myapp.application..")
        .adapter("cli", "com.myapp.adapter.cli..")
        .adapter("persistence", "com.myapp.adapter.persistence..")
        .adapter("rest", "com.myapp.adapter.rest..");

참고자료