2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

アーキテクチャテストAdvent Calendar 2020

Day 24

ArchUnit 実践:Clean Architecture のアーキテクチャテスト

Last updated at Posted at 2020-12-24
// 実行環境
* AdoptOpenJDK 11.0.9.1+1
* Spring Boot 2.4.0
* JUnit 5.7.0
* ArchUnit 0.14.1

レイヤーの依存関係

基本的な考え方は、23 日目の ArchUnit 実践:Onion Architecture のアーキテクチャテスト のオニオンアーキテクチャと同じ。

  • 依存の方向は外側の層から内側の層への一方通行
  • アダプター同士は依存しない

image.png

  • Interface Adapters 層と Application Business Rules 層の関係について、制御の方向と依存の方向が逆転している

image.png

Java プロジェクトのパッケージ構成

image.png

レイヤーとパッケージの対応関係

  • Enterprise Business Rules レイヤー
    • domain パッケージ
  • Application Business Rules レイヤー
    • application パッケージ
  • Interface Adapters レイヤー
    • Controllers アダプター
      • api パッケージ
    • Gateways アダプター
      • persistence パッケージ
    • Presenters アダプター
      • :pencil: Web アプリケーションフレームワークに Spring Boot を使用する想定で、レスポンスデータのレンダリングはフレームワークに任せることになるため、実装は省略する

Controller の実装イメージ

package com.example.api.employee;

import com.example.application.employee.EmployeeEditInputData;
import com.example.application.employee.EmployeeEditOutputData;
import com.example.application.employee.EmployeeEditUseCase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class EmployeeController {

    private final EmployeeEditUseCase editUseCase;

    public EmployeeController(final EmployeeEditUseCase editUseCase) {
        this.editUseCase = editUseCase;
    }

    @PutMapping("/employees/{id}")
    public ResponseEntity<EmployeeEditResponse> edit(
        @PathVariable final int id,
        @RequestBody final EmployeeEditRequest request
    ) {
        EmployeeEditInputData inputData = request.toInputData(id);

        EmployeeEditOutputData outputData = editUseCase.execute(inputData);

        EmployeeEditResponse response = EmployeeEditResponse.fromOutputData(outputData);

        return ResponseEntity.ok(response);
    }
}

アーキテクチャテストの実装

23 日目の ArchUnit 実践:Onion Architecture のアーキテクチャテスト に、Interface Adapters 層と Application Business Rules 層の関係(ControllerはUseCaseに実処理を委譲する)を表現するアーキテクチャテストを追加。

package com.example;

import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.AccessTarget;
import com.tngtech.archunit.core.domain.JavaAccess;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import static com.tngtech.archunit.library.Architectures.onionArchitecture;

class ArchitectureTest {

    // 検査対象のクラス
    private static final JavaClasses CLASSES =
            new ClassFileImporter()
                    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
                    .importPackages("com.example");

    @Test
    void パッケージの依存関係() {
        onionArchitecture()
            // Enterprise Business Rules
            .domainModels("com.example.domain.model..")
            .domainServices("com.example.domain.service..")

            // Application Business Rules
            .applicationServices("com.example.application..")

            // Interface Adapters
            .adapter("controllers", "com.example.api..")
            .adapter("gateways", "com.example.persistence..")

            .check(CLASSES);
    }

    @Test
    void ControllerはUseCaseに実処理を委譲する() {
        classes()
            .that()
            .resideInAPackage("com.example.api..")
            .and().areAnnotatedWith(RestController.class)
            .should(new ArchCondition<>("delegate the process to UseCases") {
                @Override
                public void check(final JavaClass controller, final ConditionEvents events) {
                    JavaConstructor constructor = controller.getConstructors()
                        .stream().findFirst().orElseThrow();
                    boolean isUseCaseInjectedIntoConstructor = constructor.getRawParameterTypes()
                        .stream().anyMatch(this::isUseCase);

                    if (! isUseCaseInjectedIntoConstructor) {
                        // UseCase がコンストラクタインジェクションされていない場合
                        events.add(SimpleConditionEvent.violated(controller, String.format(
                            "`%s` does not have the parameter type of UseCase.", constructor.getFullName())));
                        return;
                    }

                    controller.getMethods().stream()
                        .filter(this::isRequestHandler)
                        .forEach(handler -> {
                            boolean doesHandlerExecuteUseCase = handler.getMethodCallsFromSelf()
                                .stream().anyMatch(this::isUseCase);

                            if (! doesHandlerExecuteUseCase) {
                                // ハンドラーメソッドが UseCase を実行していない場合
                                events.add(SimpleConditionEvent.violated(controller, String.format(
                                    "`%s` does not execute the method of UseCase.", handler.getFullName())));
                            }
                        });
                }

                private boolean isUseCase(final JavaClass clazz) {
                    return isUseCaseInterface(clazz) && clazz.getMethods().stream().anyMatch(this::isUseCaseMethod);
                }

                private boolean isUseCase(final JavaMethodCall methodCall) {
                    return isUseCaseInterface(methodCall.getTargetOwner()) && isUseCaseMethod(methodCall.getTarget());
                }

                private boolean isUseCaseInterface(final JavaClass clazz) {
                    return clazz.getPackageName().startsWith("com.example.application")
                        && clazz.getSimpleName().endsWith("UseCase")
                        && clazz.isInterface();
                }

                private <T extends HasParameterTypes & HasReturnType> boolean isUseCaseMethod(final T method) {
                    return method.getRawParameterTypes().size() == 1
                        && method.getRawParameterTypes().get(0).getSimpleName().endsWith("InputData")
                        && method.getRawReturnType().getSimpleName().endsWith("OutputData");
                }

                private boolean isRequestHandler(final JavaMethod method) {
                    return method.isAnnotatedWith(RequestMapping.class)
                        || method.isAnnotatedWith(GetMapping.class)
                        || method.isAnnotatedWith(PostMapping.class)
                        || method.isAnnotatedWith(PutMapping.class)
                        || method.isAnnotatedWith(PatchMapping.class)
                        || method.isAnnotatedWith(DeleteMapping.class);
                }
            })
            .check(CLASSES);
    }
}

アーキテクチャテストの実行例

テスト失敗例①

Controller クラスが UseCase インターフェイスではなく、その具象クラスに依存していまっている、というアーキテクチャ違反を検知した想定でのテスト失敗例。

$ ./gradlew clean check

> Task :test

ArchitectureTest > ControllerはUseCaseに実処理を委譲する() FAILED
    java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package 'com.example.api..' and are annotated with @RestController should delegate the process to UseCases' was violated (1 times):
    `com.example.api.employee.EmployeeController.<init>(com.example.application.employee.EmployeeEditInteractor)` does not have the parameter type of UseCase.
        at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
        at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:82)
        at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:198)
        at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
        at com.example.ArchitectureTest.ControllerはUseCaseに実処理を委譲する(ArchitectureTest.java:675)

2 tests completed, 1 failed

テスト失敗例②

Controller のハンドラーメソッドが、UseCase のメソッドを実行していない(=UseCase に処理を委譲していない)、というアーキテクチャ違反を検知した想定でのテスト失敗例。

$ ./gradlew clean check

> Task :test

ArchitectureTest > ControllerはUseCaseに実処理を委譲する() FAILED
    java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package 'com.example.api..' and are annotated with @RestController should delegate the process to UseCases' was violated (1 times):
    `com.example.api.employee.EmployeeController.edit(int, com.example.api.employee.EmployeeEditRequest)` does not execute the method of UseCase.
        at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
        at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:82)
        at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:198)
        at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
        at com.example.ArchitectureTest.ControllerはUseCaseに実処理を委譲する(ArchitectureTest.java:673)

2 tests completed, 1 failed
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?