Blog

Testen von Anmerkungsprozessoren

Andrew Phillips

Aktualisiert Oktober 23, 2025
7 Minuten

Vor kurzem habe ich einen Annotation Processor für das @Composite Projekt geschrieben. In guter TDD-Manier bedeutete das in erster Linie, dass ich einige Tests schreiben musste. Obwohl ich am Ende auf etwas gestoßen bin, das ziemlich praktikabel war, war es schwieriger, als man vielleicht gehofft hatte.

Verspottet von Spöttern

Die javax.lang.model API besteht fast ausschließlich aus Schnittstellen. Prädestiniert für Mocking, könnte man sagen. Los geht's also mit dem zu testenden Code: [java] LeafAnnotationElementValidator(ExecutableElement element, Types typeUtils, Elements elementUtils) { // die Methode muss Mitglied einer zusammengesetzten Annotation sein Element enclosingElement = element.getEnclosingElement(); if (!ElementUtils.isAnnotation(enclosingElement) || (enclosingElement.getAnnotation(CompositeAnnotation.class) == null)) { ... } ... } [/java] Element, richtig, das kommentierte Element, das wir für diesen Test verarbeiten möchten. OK, eine Attrappe hier. typeUtils und elementUtils, zwei weitere Attrappen-Parameter. Dann ein Aufruf von element, der eine weitere Attrappe zurückgibt. Und so weiter. Fünf Minuten, zig Mocks und noch mehr Mock-Erwartungen später, habe ich aufgegeben.

Der falsche Cocktail

Warum fühlte sich dieser Ansatz so falsch an? Es lag nicht nur an den vielen Codezeilen, die die "eigentliche" Testlogik bei weitem übertrafen. Ich habe versucht, das Verhalten des Prozessors anhand bestimmter Codefragmente zu testen, aber dieser Code war nirgends zu sehen. Bestenfalls konnte man sein verschwommenes Spiegelbild in der Fülle von Mock-Experiences erahnen, aber man musste sich schon ziemlich gut mit der Model-API auskennen, um damit etwas anfangen zu können. Was ich wirklich wollte, war eine Mirrorgarita1, etwas, das es mir erlauben würde, etwas wie: [java] @Test public boolean validatorTest() { Element mockElement = Mirrorgarita.createMockElement(TestAnnotation.class.getMethod(...)); LeafAnnotationElementValidator validator = new LeafAnnotationElementValidator( mockElement, Mirrorgarita.newTypesMock(), Mirrorgarita.newElementsMock()); assertSomething(validator...); } [/java] Hier wird etwas deutlicher, dass ich versuche zu sehen, wie sich der Validator bei der Verarbeitung der Modell-API-Darstellung einer bestimmten Methode verhält. Und wenn sich der Code für diese Methode in der Testklasse befindet(die TestAnnotation könnte im Test deklariert werden), kann ich diesen Codeschnipsel untersuchen und, besser noch, ihn bei Bedarf ändern. Das ist sicherlich einfacher, als die Mock-Erwartungen zu optimieren.2 Leider scheint Mirrorgarita jedoch nicht zu existieren - zumindest konnte ich sie bei meiner flüchtigen Suche nicht finden. Ja, es gibt Elements.getTypeElementdie das TypeElement für (d.h. die Modell-API-Darstellung) einer Klasse oder Schnittstelle mit einem bestimmten Namen zurückgibt. Aber die einzige Möglichkeit, an eine Elements-Instanz heranzukommen, ist über ProcessingEnvironment.getElementUtilsund das ist leider nur möglich... wenn Sie tatsächlich Anmerkungen verarbeiten, d.h. innerhalb Ihres Prozessors. Nicht in einer Testklasse. Seufz.

Der echte McCoy

Wir vermissen also eine Möglichkeit, Codesegmente bequem in ihre Modell-API-Darstellung zu konvertieren. Nun, zum Glück gibt es einen alten Freund, an den man sich für solche Umwandlungen immer wenden kann...javac. Was, Runtime.exec? Mit all der systembedingten Sprödigkeit, die das mit sich bringt? Die Verletzung des heiligen Prinzips der Plattformunabhängigkeit...in einem Test? Nun, zum Glück ist dies Java 6, so dass das alles nicht nötig ist. Die Compiler-API kommt uns zu Hilfe. Natürlich bedeutet der Aufruf des Compilers das Kompilieren ganzer Klassen im Gegensatz zu Codeschnipseln und erlaubt uns nur, den Anmerkungsprozessor als Ganzes zu testen. Wenn Sie Ihren Anmerkungsprozessor modularisiert haben, handelt es sich eigentlich um Integrationstests und nicht um Unit-Tests. Aber da Sie die Kontrolle über den Quellcode haben, der kompiliert wird, sollte es ein Leichtes sein, Beispiele zu finden, die einzelne Teile der Funktionalität des Prozessors testen. Etwas unbequemer ist die Tatsache, dass mit diesem Ansatz nur Codepfade getestet werden können, die das Ergebnis der Kompilierung beeinflussen, z.B. indem sie einen Fehler auslösen oder eine Warnung ausgeben, oder die einen Nebeneffekt erzeugen, z.B. indem sie eine Datei (in) eine Datei schreiben. Jede "interne" Logik, die nichts von alledem tut, ist auf diese Weise nicht überprüfbar. Bei der Überprüfung des erwarteten Ergebnisses der Kompilierung reicht es in der Regel nicht aus, zu sagen "es sollte ein Fehler auftreten". Im Falle dieses Validierungsprozessors möchte ich zum Beispiel sicher sein, dass der zurückgegebene Fehler wirklich durch den fehlerhaften Code verursacht wird und nicht durch einen Fehler im Prozessor, der den falschen Code akzeptiert, aber fälschlicherweise einen Fehler an anderer Stelle meldet.Da die Überprüfung auf Fehlermeldungen furchtbar brüchig ist, scheint das Beste, was man hier tun kann, darin zu bestehen, Fehler an einer bestimmten Stelle, d.h. einer Codezeile, zu erwarten. Das ist geringfügig besser und scheint zu funktionieren, riecht aber immer noch ziemlich verdächtig.

Im Code

Genug geredet. Hier ist ein Codebeispiel: [java] public class CompositeAnnotationValidationProcessorTest extends AbstractAnnotationProcessorTest { @Override protected Collection<Processor> getProcessors() { return Arrays.<Processor> asList(new CompositeAnnotationValidationProcessor()); } @Test public void leafAnnotationOnNonCompositeMember() { assertCompilationReturned(Kind.ERROR, 22, compileTestCase(InvalidLeafAnnotationUsage.class)); } @Test public void validCompositeAnnotation() { assertCompilationSuccessful(compileTestCase(ValidCompositeAnnotation.class)); } } [/java] Hier gibt getProcessors die Instanzen der Anmerkungsprozessoren zurück, die während der Kompilierung aufgerufen werden sollen. Der erste Test erwartet, dass die Kompilierung der Klasse InvalidLeafAnnotationUsage in Zeile 22 einen Fehler zurückgibt, während der zweite Test erwartet, dass die Kompilierung von ValidCompositeAnnotation erfolgreich ist, d.h. keine Fehler enthält3. Die Methode compileTestCase der Klasse AbstractAnnotationProcessorTest Basisklasse sieht unterdessen wie folgt aus4: [java] protected Liste<Diagnostik<? extends JavaFileObject >> compileTestCase( String... compilationUnitPaths) { Kollektion<Datei> compilationUnits; versuchen { compilationUnits = findClasspathFiles(compilationUnitPaths); } catch (IOException exception) { throw new IllegalArgumentException(...); } DiagnosticCollector<JavaFileObject> diagnosticCollector = new DiagnosticCollector<JavaFileObject>(); StandardJavaFileManager fileManager = COMPILER.getStandardFileManager(diagnosticCollector, null, null); CompilationTask task = COMPILER.getTask(null, fileManager, diagnosticCollector, Arrays.asList("-proc:only"), null, fileManager.getJavaFileObjectsFromFiles(compilationUnits)); task.setProcessors(getProcessors()); task.call(); versuchen { fileManager.close(); } catch (IOException exception) {} return diagnosticCollector.getDiagnostics(); } [/java]

Geschichten aus einem Paralleluniversum Der Anmerkungsprozessor war zwar ein nettes Extra, aber die Validierungen, die er durchführte, waren es ganz sicher nicht. Als ich mit der Arbeit an dem Prozessor begann, hoffte ich daher insgeheim, einen Weg zu finden, den Validierungscode zur Laufzeit wiederzuverwenden, der auf die kompilierten Klassen und Anmerkungen angewendet wurde.Das war natürlich nicht ganz einfach, denn die verfügbaren Informationen unterscheiden sich (in einigen Bereichen, z.B. bei Generika, erheblich) von dem, was zur Laufzeit bekannt ist. Aber in den meisten Bereichen sind sie ähnlich genug, um zu glauben, dass es für "einfache" Dinge wie das Abrufen aller Anmerkungen einer Klasse oder der Modifikatoren einer Methode möglich sein könnte, eine Implementierung zu entwickeln, die sowohl für die Kompilierung als auch für die Laufzeit geeignet ist. Leider war es nicht so (und vielleicht war es naiv, so viel zu erwarten). Die Modell- und die Laufzeit-Reflection-APIs sind fast vollständig voneinander getrennt. Einige wenige Methoden überschreiten die Grenze, z.B. Element.getAnnotation, aber die dazugehörigen JavaDoc-Kommentare machen Sie sicherlich auf die damit verbundenen Schwierigkeiten aufmerksam. So wie es aussieht, haben Sie am Ende eine mäßig frustrierende Menge an Doppelungen(Types.isAssignable(subType, superType) für classForSuperType.isAssignableFrom(classForSubType) usw.). Noch unangenehmer ist, dass die meisten der Hilfsmethoden, die ihren Weg in die verschiedenen Klasse- und ReflectionUilts gefunden haben, sind für die Modell-API nicht verfügbar. Es gibt die Hilfsklassen Elements und Types, aber sie sind begrenzter als die für die Runtime Reflection API verfügbaren.Schließlich habe ich eine Klasse ElementUtils geschrieben, um dieses Problem zu lösen. Ihr Umfang ist zwar immer noch auf das beschränkt, was für den Validierungsprozessor erforderlich war, aber ich hoffe, dass sie zumindest eine nützliche Grundlage für etwas Umfassenderes darstellt.
Fußnoten
  1. Entschuldigen Sie das schreckliche Wortspiel mit dem Namen des etwas bekannteren Cocktail-Frameworks.
  2. Beachten Sie, wie vergleichsweise einfach dies für die Runtime Reflection API zu bewerkstelligen ist. Wenn ich einen Code testen möchte, der die Methodendarstellung einer privaten, statischen Methode erfordert, deklariere ich einfach irgendwo private static myMethod() und verwende MyMethodHolderClass.class.getDeclaredMethod um sie abzurufen.
  3. Um die Testfälle zu kompilieren, müssen sich die Quelldateien - die .java-Dateien - auf dem Testklassenpfad befinden. In Maven erfordert dies einen kleinen Trick, ähnlich wie [xml] <bauen> <testResources> <testResource> <Verzeichnis>${basedir}/src/test/resources</verzeichnisse> </testResource> <!-- ein Verzeichnis mit Quelldateien hinzufügen, die während eines Tests kompiliert werden müssen--> <testResource> <Verzeichnis>${project.build.testSourceDirectory}/path/to/test/samples</directory> <targetPath>path/to/test/samples</targetPath> </testResource> </testResources> ... </build> [/xml] was den unglücklichen Nebeneffekt hat, dass das Maven Eclipse Plugin einen zusätzlichen Quellordner hinzufügt. Dieser muss aus dem Build-Pfad in Eclipse entfernt werden.
  4. Auf den ersten Blick erscheint das fünfte Klassenargument der (eher spärlich dokumentierten) JavaCompiler.getTask Methode scheint auf den ersten Blick perfekt für die Verarbeitung von Anmerkungen geeignet zu sein, ohne eine vollständige Kompilierung durchführen zu müssen: perfekt für unsere Zwecke. Leider funktioniert das nicht ganz: Die Anmerkungen zu den genannten Klassen werden an den Prozessor übergeben, aber wenn die genannten Klassen selbst Anmerkungen sind (und daher möglicherweise validiert werden müssen), sind sie nicht über die RoundEnvironmentzugänglich, vermutlich weil sie nicht kompiliert werden.

Verfasst von

Andrew Phillips

Contact

Let’s discuss how we can support your journey.