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.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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")));
}
}
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"))); } }
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
@Hamcrest Annotation. Diese Annotation hat die Klasse Person als Parameter, für die eigene Matcher benötigt werden.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Hamcrest {
Class<?> value();
}
@Target(ElementType.TYPE) @Retention(RetentionPolicy.SOURCE) public @interface Hamcrest { Class<?> value(); }
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Hamcrest {
  Class<?> value();
}

Die

@Hamcrest
@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
PersonenMatcher zu sehen. Genau diese Klasse wird erzeugt, wenn die Test-Klasse mit der
@Hamcrest
@Hamcrest Annotation, das erste Mal übersetzt wird.

Die erzeugten Matcher-Klassen entsprechen immer den folgenden Beispiel.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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();
}
};
}
}
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(); } }; } }
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
Matchers. Für alle Getter der Ziel-Klasse wird eine statische Methode erzeugt, die einen
FeatureMapper
FeatureMapper
für dieses Attribut erzeugt.

Der

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

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@SupportedAnnotationTypes("de.schegge.processor.annotations.Hamcrest")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@AutoService(Processor.class)
public class HamcrestMatcherBuilderProcessor extends AbstractProcessor {
}
@SupportedAnnotationTypes("de.schegge.processor.annotations.Hamcrest") @SupportedSourceVersion(SourceVersion.RELEASE_11) @AutoService(Processor.class) public class HamcrestMatcherBuilderProcessor extends AbstractProcessor { }
@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
META-INF/service/javax.annotation.processing.Processor Datei aufgeführt werden.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
de.schegge.processor.impl.HamcrestMatcherBuilderProcessor
de.schegge.processor.impl.HamcrestMatcherBuilderProcessor
de.schegge.processor.impl.HamcrestMatcherBuilderProcessor

Im Fall des

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

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@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;
}
@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; }
@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
TypeElement in der Methode
extractSourceClass
extractSourceClass die benötigte Ziel-Klasse extrahiert. Da eine Klassen in mehreren @Hamcrest Annotationen auftauchen kann, werden mit
distinct
distinct mögliche Dubletten aus dem
Stream
Stream entfernt.

Die eigentliche Arbeit geschieht in der

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

2 thoughts on “Hamcrest Matcher Generator”

Comments are closed.