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
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]
- Entschuldigen Sie das schreckliche Wortspiel mit dem Namen des etwas bekannteren Cocktail-Frameworks.
- 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.
- 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.
- 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
Unsere Ideen
Weitere Blogs
Contact



