[JAVA] ArchUnit Practice: Clean Architecture Architecture Testing

//Execution environment
* AdoptOpenJDK 11.0.9.1+1
* Spring Boot 2.4.0
* JUnit 5.7.0
* ArchUnit 0.14.1

Layer dependencies

The basic idea is the same as the onion architecture of ArchUnit Practice: Onion Architecture Architecture Test on the 23rd day.

image.png

image.png

Java project package structure

image.png

Correspondence between layers and packages

Controller implementation image

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);
    }
}

Architecture test implementation

Added an architecture test to the ArchUnit practice: Onion Architecture architecture test on the 23rd day to express the relationship between the Interface Adapters layer and the Application Business Rules layer (Controller delegates actual processing to 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 {

    //Class to be inspected
    private static final JavaClasses CLASSES =
            new ClassFileImporter()
                    .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
                    .importPackages("com.example");

    @Test
void package dependencies() {
        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 delegates actual processing to 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) {
                        //If UseCase is not constructor injected
                        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) {
                                //If the handler method is not executing 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);
    }
}

Architecture test execution example

Test failure example ①

An example of a test failure assuming that an architecture violation was detected in which the Controller class relied on its concrete class instead of the UseCase interface.

$ ./gradlew clean check

> Task :test

ArchitectureTest >Controller delegates actual processing to 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 delegates actual processing to UseCase(ArchitectureTest.java:675)

2 tests completed, 1 failed

Test failure example ②

An example of test failure on the assumption that the handler method of Controller does not execute the method of UseCase (= processing is not delegated to UseCase), which is an architecture violation.

$ ./gradlew clean check

> Task :test

ArchitectureTest >Controller delegates actual processing to 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 delegates actual processing to UseCase(ArchitectureTest.java:673)

2 tests completed, 1 failed

Recommended Posts

ArchUnit Practice: Clean Architecture Architecture Testing
ArchUnit Practice: Architecture Testing of Onion Architecture
Unit test architecture using ArchUnit
Consideration about Rails and Clean Architecture