How to use ArchUnit to enforce design & practical examples.

Viktor Reinok
3 min readJun 14, 2023

… to enforce design to add structure to your project and save mental energy while doing everyday development.

First, let's import ArchUnit dependency into a Maven project

<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>1.2.1</version>
<scope>test</scope>
</dependency>

Ensuring tests get written

For example, we want to ensure that particular classes are getting always tested without running less strict code coverage. Here is an example that assures that for every class called Mapper, there must be a class called MapperTest, so one would:

  1. Be reminded to write a test.
  2. Assure that a Test is written for a bug-prone element of design.
@AnalyzeClasses(packages = {"ch.blog.archunit"})
public class MapperArchUnitTest {

private static final String MAPPER_CLASS_SUFFIX = "Mapper";
private static final String TEST_CLASS_SUFFIX = "Test";

@ArchTest
public static final ArchRule relevant_classes_should_have_tests =
mapperClassesAndTest()
.should(haveACorrespondingClassEndingWith(TEST_CLASS_SUFFIX));

private static GivenClassesConjunction mapperClassesAndTest() {
return classes()
.that()
.haveSimpleNameContaining(MAPPER_CLASS_SUFFIX).and()
.areTopLevelClasses()
.and().areNotInterfaces()
.and().areNotRecords()
.and().areNotEnums();
}

private static ArchCondition<JavaClass> haveACorrespondingClassEndingWith(String testClassSuffix) {
return new ArchCondition<>("have a corresponding class with suffix " + testClassSuffix) {
Set<String> testedClasseNames = emptySet();

@Override
public void init(Collection<JavaClass> allClasses) {
testedClasseNames = allClasses.stream()
.map(JavaClass::getName)
.filter(className -> className.endsWith(testClassSuffix))
.map(className -> className.substring(0, className.length() - testClassSuffix.length()))
.collect(toSet());
}

@Override
public void check(JavaClass clazz, ConditionEvents events) {
if (!clazz.getName().endsWith(testClassSuffix)) {
boolean satisfied = testedClasseNames.contains(clazz.getName());
String message = createMessage(clazz, "has " + (satisfied ? "a" : "no") + " corresponding test class");
events.add(new SimpleConditionEvent(clazz, satisfied, message));
}
}
};
}
}

A lot of out-of-the-box tests

Archunit provides many prebuild tests. An example that checks printf style debugging leftovers and a legacy dependency. [2]

@AnalyzeClasses(packages = {"ch.blog.archunit"})
public class GeneralArchUnitTest {

@ArchTest
public static final ArchRule should_not_use_strd_streams = noClasses().should(GeneralCodingRules.ACCESS_STANDARD_STREAMS);

@ArchTest
public static final ArchRule should_not_use_joda_time = noClasses().should(GeneralCodingRules.USE_JODATIME);

}

Ensuring structure for integrations

Here is an example that forces certain dependencies to be in certain packages — an easy way to structure the integrations.

public class MavenDependencyImportTest {

private static final String ALLOWED_PACKAGE = "eu.cityx.rp.service.integration";
private static final String MAVEN_DEPENDENCY = "ee.sk.smartid:digidoc4j-smart-id-adapter";

@Test
public void testMavenDependencyImport() {
JavaClasses importedClasses = new ClassFileImporter().importPackages("eu.cityx.rp");

ArchRule rule = ArchRuleDefinition.noClasses().should()
.dependOnClassesThat()
.resideOutsideOfPackage(ALLOWED_PACKAGE)
.andShould()
.dependOnClassesThat()
.resideInAPackage(getPackageRegexForDependency(MAVEN_DEPENDENCY));

rule.check(importedClasses);
}

private String getPackageRegexForDependency(String mavenDependency) {
// Convert Maven dependency to a regex pattern for the package name
String[] dependencyParts = mavenDependency.split(":");
String groupId = dependencyParts[0].replaceAll("\\.", "\\\\.");
String artifactId = dependencyParts[1].replaceAll("\\.", "\\\\.");
// add logic to gid the path out of the jar
return "org.digidoc4j.*";
}
}

What to do in case you have a lot of violations but you still want to enforce the rule.

There is a functionality called freeze. What you have to do is just wrap your test with freeze and define a nicer name for all the existing violations.

   @ArchTest
public static final ArchRule mapper_classes_should_have_tests =
freeze(mapperClassesAndTest()
.should(haveACorrespondingClassEndingWith(TEST_CLASS_SUFFIX)))
.persistIn(new TextFileBasedViolationStore(a -> "violations_mapper_classes_should_have_tests.txt"));

Remember to add the archunit.properties file to the test resources

Test that ensures that certain classes are in certain packages

The following test ensures that every domain package has a separate package for mappers

@ArchTest
public static final ArchRule mapper_classes_should_reside_in_mapper_package = classes()
.that().haveSimpleNameEndingWith("Mapper")
.should().resideInAPackage("..mapper..");

References

  1. Examples GitHub repository
    https://github.com/vikreinok/archunit-examples
  2. https://github.com/TNG/ArchUnit-Examples/tree/main/example-junit5/src/test/java/com/tngtech/archunit/exampletest/junit5
  3. https://www.youtube.com/watch?v=ef0lUToWxI8
  4. https://www.youtube.com/watch?v=XgVlEagYA_w

--

--