Testabdeckung mit ArchUnit sicherstellen

In einem früheren Artikel haben wir eine Einführung in das gesehen, was ArchUnit in Bezug auf die Durchsetzung von Codierungsstandards leisten kann. Das ist großartig. Nicht nur ist euer Code beim Refactoring durch vorhandene Tests geschützt, sondern mit Hilfe von ArchUnit lassen sich Architekturentscheidungen auf Testebene leicht durchsetzen und pflegen, was den Code-Review-Prozess entlastet.
Ein weiteres Detail, das üblicherweise im Code-Review-Prozess überprüft wird, ist das Schreiben eben jener Tests. Oft taucht ein Kommentar wie „braucht mehr Tests” auf, der den Code zurück an den Entwickler schickt. Wie wir sehen werden, kann neben der Sicherstellung der architektonischen Einheitlichkeit durch eine clevere Nutzung von ArchUnit und Namenskonventionen auch die Testabdeckung auf Testebene sichergestellt werden.
Sicherstellen, dass Klassen ihre äquivalenten Testklassen haben
Angenommen, die erste Regel, die wir durchsetzen möchten, ist, dass für alle Controller, Services und Repositories eine Testklasse erstellt wird. ArchUnit macht das Schreiben dieser Regel sehr einfach – wir müssen lediglich eine benutzerdefinierte Bedingung implementieren.
@ArchTest
static final ArchRule ensureTestClasses =
classes()
.that()
.areAnnotatedWith(RestController.class)
.or()
.areAnnotatedWith(Service.class)
.should(haveTheirEquivalentTestClass());
The only method that we need to write is haveTheirEquivalentTestClass().
private static ArchCondition<? super JavaClass> haveTheirEquivalentTestClass() {
return new ArchCondition<>("have associated test classes") {
@Override
public void check(JavaClass item, ConditionEvents events) {
final String className = item.getSimpleName();
final boolean hasTestClass = getEquivalentTestClasses(item).size() > 0;
if (!hasTestClass) {
events.add(
new SimpleConditionEvent(
item, false, "%s does not have a test class".formatted(className)));
}
}
};
}
Die Klasse ArchCondition wird verwendet, um die erforderlichen Bedingungen zu überprüfen. Ihre check()-Methode akzeptiert einen JavaClass-Parameter (unsere Controller, Services oder Repositories) und ConditionEvents, der zur Übergabe etwaiger Verstöße verwendet wird.
Die Methode getEquivalentTestClasses(item) ruft alle Testklassen ab, die der als Parameter übergebenen Klasse nach den folgenden Konventionen zugeordnet sind:
- Sie muss sich im selben Package befinden
- Sie muss die Form ClassNameTest oder ClassNameIntegrationTest haben
Natürlich können je nach Bedarf und Kontext des Projekts verschiedene Konventionen implementiert werden. Das erzwingt indirekt eine Namenskonventionskonsistenz für Testklassen, was ein zusätzlicher Vorteil ist.
Wie ihr vielleicht bemerkt habt, fehlen Verweise auf Repository-Beans. Diese behandeln wir im nächsten Abschnitt, in dem ich zeige, wie sich ein Prozentsatz der Methodenabdeckung für bestimmte Bean-Typen durchsetzen lässt; dafür schreibe ich eine weitere Methode.
Prozentualer Anteil der Methodentestabdeckung
Einfach ausgedrückt möchten wir möglicherweise eine x%-Testabdeckung für einige oder alle unsere Klassen erzwingen. Angenommen, wir erzwingen eine 100%-Methodentestabdeckung für unsere Repositories. Was bedeutet das? Zunächst stellen wir sicher, dass alle unsere Repositories eine zugehörige Testklasse haben (wie im vorherigen Abschnitt erklärt), und dann stellen wir sicher, dass alle deklarierten Methoden eine zugehörige Methode mit demselben Namen (oder einer anderen Konvention folgend) in einer dieser Testklassen haben.
public static final double MIN_COVERAGE_REPOS = 1.0;
@ArchTest
static final ArchRule fullMethodTestCoverageForRepositories =
classes()
.that()
.areMetaAnnotatedWith(Repository.class)
.should(haveTheirEquivalentTestClass())
.andShould(havePercentMethodCoverage(MIN_COV_REPOS));
So how should the havePercentMethodCoverage(MIN_COV_REPOS) work?
Es soll alle öffentlichen Methoden der ausgewerteten Klasse nehmen, alle öffentlichen Methoden der zugehörigen Testklassen nehmen, die aus der Testklasse von denen der ausgewerteten Klasse subtrahieren und basierend auf dem in der Methode übergebenen Prozentsatz entscheiden, ob ein SimpleConditionEvent-Alert hinzugefügt werden soll.
Nach dem Ausführen der Tests – angenommen, eine @Service-Klasse mit 3 öffentlichen Methoden, 1 privaten, aber nur 2 dieser öffentlichen Methoden haben ihr Äquivalent in der Testklasse – sollte die Meldung beim Ausführen der ArchUnit-Tests wie folgt aussehen:

(vorausgesetzt, alles über 0,667 wird als minimale Testabdeckung im Parameter übergeben)
Als letzten Schliff können ausgeschlossene Klassen hinzugefügt werden. Angenommen, aus welchem Grund auch immer müssen einige Klassen von diesen Prüfungen ausgeschlossen werden. Das ist sehr einfach umzusetzen. Alles, was benötigt wird, ist ein Array von Klassennamen, die überprüft werden, bevor eine dieser benutzerdefinierten ArchUnit-Methoden Prüfungen durchführt.
private static final String[] excludedClasses = {
"ExcludedService",
};
...
public void check(JavaClass clasz, ConditionEvents events) {
final String className = clasz.getSimpleName();
if (Arrays.asList(excludedClasses).contains(className)) {
return;
}
...
In the end, havePercentMethodCoverage() method will look like this
private static ArchCondition<? super JavaClass> havePercentMethodCoverage(double coverage) {
return new ArchCondition<>("have most their methods covered") {
@Override
public void check(JavaClass clasz, ConditionEvents events) {
final String className = clasz.getSimpleName();
if (Arrays.asList(excludedClasses).contains(className)) {
return;
}
final List<JavaClass> equivalentTestClasses = getEquivalentTestClasses(clasz);
if (!equivalentTestClasses.isEmpty()) {
final List<String> classMethods =
getPublicNonStaticMethodsOfClass(clasz)
.stream().map(JavaMember::getName)
.toList();
final Set<String> testMethods =
equivalentTestClasses.stream()
.map(CoverageTests::getPublicNonStaticMethodsOfClass)
.flatMap(List::stream)
.map(JavaMember::getName)
.collect(Collectors.toSet());
final List<String> missingMethods =
classMethods.stream()
.filter(method -> !testMethods.contains(method))
.collect(Collectors.toList());
final int claszMethodsSize = classMethods.size();
final double percentMissing = (double) missingMethods.size() / claszMethodsSize;
if (1 - percentMissing < coverage) {
events.add(
new SimpleConditionEvent(
clasz, false,
"%s has just %.2f%% (%d/%d) of its methods covered [missing: %s]".formatted(className, (1 - percentMissing) * 100,(claszMethodsSize - missingMethods.size()), claszMethodsSize,String.join(", ", missingMethods))));
}
}
}
};
}
Einschränkungen
Es gibt offensichtliche Grenzen bei der Verwendung von etwas wie diesem. Sollte ein „trickreicher Entwickler“ diese Bedingungen austricksen wollen, könnte er einfach leere Testmethoden anlegen – und die Prüfungen hätten keine Handhabe, sie als etwas anderes als abgedeckt zu werten. Oft jedoch weckt das Sehen einer leeren Testmethode eher den Wunsch, sie zu füllen. Darüber hinaus setzen wir gute Absichten von den Menschen voraus, mit denen wir zusammenarbeiten – eine notwendige Voraussetzung dafür, dass jedes Team Höchstleistungen erbringen kann.
Dies deckt auch nichts im Hinblick auf zyklomatische Komplexität, verzweigte Methoden und – nun ja – alles innerhalb dieser Tests oder äquivalenten Methoden ab. Genau wie im vorher verlinkten Einführungsartikel zu ArchUnit überprüfen diese Tests keine Substanz – ihr Zweck ist die Durchsetzung von Form.
Den gesamten Quellcode findet ihr in diesem Repository.
Agent Hub
Gehen Sie über vereinzelte AI-Piloten hinaus und schaffen Sie eine sichere, skalierbare Grundlage für Agentic AI in Ihrer gesamten Organisation.
Zur Demo




