Hamcrest Matcher Generator

Im AssertJ Projekt findet sich ein Generator, mit dem automatisch Assertions für vorgegebene Klassen generiert werden können. Damit wird dem Test Entwickler die eintönige Arbeit abgenommen, diese Assertions selbst zu schreiben. Ohne solche Assertions können die Möglichkeiten eines Framework, wie AssertJ oder Hamcrest nicht optimal genutzt werden.

Wer lieber mit Hamcrest arbeitet, muss auf solch ein Feature nicht zwangsläufig verzichten. Wie ein eigener Assertion Generator für Hamcrest erstellt werden kann, wird in diesem und weiteren Beiträgen erläutert.

Im folgenden ist ein einfacher JUnit Test dargestellt, der mit Hamcrest Matchern eine Person Instanz prüft.

import static de.schegge.ancestor.PersonsMatcher.hasName;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;

import de.schegge.ancestor.Person;
import de.schegge.processor.annotations.Hamcrest;
import org.junit.jupiter.api.Test;

@Hamcrest(Person.class)
class PersonMatcherTest {
  @Test
  void test() {
    Person person = new Person();
    person.setName("Jens Kaiser");
    assertThat(person, hasName("Jens"));
    assertThat(person, not(hasName("Fiona")));
    assertThat(person, hasName(startsWith("Kai")));
  }
}

Auffällig an dieser Test-Klasse ist die @Hamcrest Annotation. Diese Annotation hat die Klasse Person als Parameter, für die eigene Matcher benötigt werden.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Hamcrest {
  Class<?> value();
}

Die @Hamcrest Annotation steht nur in den Sourcen zur Verfügung. Dies reicht aber für den hier vorgestellt Ansatz völlig aus.

Java bietet die Möglichkeit einen Annotation Processor zur Generierung zusätzlicher Dateien in die Compilierphase einzubauen. Solche Prozessoren werden u.a. von Javadoc, Immutables, MapStruct und Lombok genutzt.

Im obigen Beispiel ist in der Klasse schon ein Import für einen PersonenMatcher zu sehen. Genau diese Klasse wird erzeugt, wenn die Test-Klasse mit der @Hamcrest Annotation, das erste Mal übersetzt wird.

Die erzeugten Matcher-Klassen entsprechen immer den folgenden Beispiel.

public final class PersonMatchers {
  public static Matcher<Person> hasName(final String name) {
    return new FeatureMatcher<Person,String>(equalTo(name), "name", "name") {
      @Override protected String featureValueOf(final Person actual) {
        return actual.getName();
      }
    };
  }
}

Der Name der Matcher-Klasse ergibt sich aus dem Namen der Ziel-Klasse und dem Suffix Matchers. Für alle Getter der Ziel-Klasse wird eine statische Methode erzeugt, die einen FeatureMapper für dieses Attribut erzeugt.

Der HamcrestMatcherBuilderProcessor erzeugt Hamcrest Matcher Klassen und verarbeitet dafür @Hamcrest Annotation. Dies wird mit der Annotation @SupportedAnnotationTypes definiert.

@SupportedAnnotationTypes("de.schegge.processor.annotations.Hamcrest")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@AutoService(Processor.class)
public class HamcrestMatcherBuilderProcessor extends AbstractProcessor {
}

Nach der ersten Compilierungsphase und dem Aufsammeln vorhandener Annotationen, wird die process Methode aller konfigurierten Prozessoren aufgerufen.

Damit dies auch für den eigenen Annotation Processor geschieht muss er in einer META-INF/service/javax.annotation.processing.Processor Datei aufgeführt werden.

de.schegge.processor.impl.HamcrestMatcherBuilderProcessor

Im Fall des HamcrestMatcherBuilderProcessor wird erst einmal geprüft, ob notwendige Properties definiert wurden. Wenn alles zu passen scheint werden die gefundenen Annotations Elemente durchlaufen und die damit annotierten Typen bearbeitet.

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
  if (properties.isEmpty()) {
    return false;
  }
  for (TypeElement annotation : annotations) {
    roundEnv.getElementsAnnotatedWith(annotation).stream()
        .map(TypeElement.class::cast).map(this::extractSourceClass)
        .filter(Objects::nonNull).distinct()
        .forEach(this::handleHamcrestAnnotation);
  }
  return true;
}

Die obige Verarbeitung konnte einfach gehalten werden, da nur eine Annotation geprüft wird und diese nur Typen annotieren kann. Für komplexere Verarbeitungen bietet die Annotation Processor API übrigens eine eigene Visitor Implementierung.

Im oben dargestellten Stream wird aus dem TypeElement in der Methode extractSourceClass die benötigte Ziel-Klasse extrahiert. Da eine Klassen in mehreren @Hamcrest Annotationen auftauchen kann, werden mit distinct mögliche Dubletten aus dem Stream entfernt.

Die eigentliche Arbeit geschieht in der handleHamcrestAnnotation. Wie dort aus der Ziel-Klasse die eigentliche Matcher-Klasse generiert wird, ist der Inhalt des nächsten Beitrags zum Hamcrest Matcher Generator.