Optional Matchers for Hamcrest

Never underestimate the power of a simple tool.

Craig Bruce

Manchmal benötigt man ein kleines Feature, das so schnell aus den Fingern fließt,  dass die Zeit nicht lohnt, nach existierenden Lösungen zu suchen. Wie enttäuscht war ich da, als ich beim Schreiben dieses Beitrags etwas ähnliches auf GitHub fand. 

Es geschieht in der Software Entwicklung tagtäglich, dass Räder neu erfunden werden. Manche groß, manche klein und manche sogar eckig. In diesem Fall werde ich aus der Not eine Tugend machen und stelle beide Lösungen etwas allgemeiner vor.

In den beiden Beiträgen Nur noch assertThat in Unit Tests und Befreit die APIs von Nullen, habe ich die Vorteile der Java 8 Optional und der Hamcrest Matcher für JUnit beleuchtet. Umso erstaunlicher, dass es noch keinen direkten Support der Optional in Hamcrest gibt.

Die Optionals umgehen das leidige Problem mit der null Prüfung und gestatten somit einen viel kompakteren und übersichtlicheren Code.. 

String birthday = person.getBirth().map(Event::getDate).map(FORMATTER::format).orElse("unknown");

Dieser Einzeiler produziert für einen GEDCOM Eintrag von Dietrich Hinrich Köhnsen den String „1809-05-05“ und für Einträge, bei denen das Datum oder das ganze Ereignis fehlt, produziert er „unknown“. 

Hamcrest Matcher ersetzen lange und unübersichtliche Listen von Asserts durch verständliche Ausdrücke. Häufig arten Überprüfungen in eigene kleine Biotope voller Subroutinen und Kontrollstrukturen aus, weil die Ergebnisse entweder kompliziert oder sehr variabel sind. Ein guter Test sollte nie variable Resultate prüfen müssen, meist ein Hinweis auf Test mit externen Datenabhängigkeiten.

Der folgende Code zeigt einen JUNIT 5 Test,  der eine Suche nach dem Namen Kaiser auf der GEDCOM Resource ausführt und im einzigen Assertion  prüft, ob das Ergebnis 42 Einträge besitzt und alle den Namen Kaiser enthalten.

@Test searchKaiser(Gedcom gedcom) {
  assertThat(gedcom.search("kaiser"), both(hasSize(42)).and(eachItem(is(lastname("Kaiser"))));
}

In diesem Beispiel ist nur der Matcher PersonMatchers#lastname selbst geschrieben, etwa 5 Zeilen lang und in Dutzenden von Test verwendet. 

Was aber nun tatsächlich in der Standardsammlung von Hamcrest fehlt, ist ein Matcher, der auf den Optional Values arbeitet. So etws ist relativ schnell implementiert, weil Hamcrest eine ganze Reihe von Basisklassen für eigene Matcher bereitstellt.

public class OptionalMatchers {
   public static final <T> Matcher<Optional<T>> present() {
      return new FeatureMatcher<Optional<T>, Boolean>(is(false), "empty", "empty") {
         @Override
         protected Boolean featureValueOf(Optional<T> actual) {
            return actual.isPresent();
         }
      };
   }

   public static final <T> Matcher<Optional<T>> optional(Matcher<T> matcher) {
      return new FeatureMatcher<Optional<T>, T>(matcher, "has value", "has value") {
         @Override
         protected T featureValueOf(Optional<T> actual) {
            return actual.orElse(null);
         }
      };
   }
}

Die obige Implementierung erstellt zwei FeatureMatcher für Optionals. Der erste prüft, ob ein Optional nicht leer ist und der andere prüft mit einem weiteren Matcher, den Wert im Optional. Die Verwendung ist einfach

assertThat(person.getDeath(), is(not(present()));
assertThat(person.getBirth().getPlace(), both(is(present)).and(is(optional(contains("Bielefeld")))));

Die erste Zeile prüft, ob das optionale Event death existiert und die zweite Zeile prüft, ob der Geburtsort Bielefeld ist. 

Die andere Möglichkeit mit Optionals zu arbeiten ist die hamcrest-optional Bibliothek. Dazu muss man diese nur als Testabhängigkeit ins Projekt einfügen und kann dann sofort die eigenen Tests pimpen.

<dependency>
    <groupId>com.github.npathai</groupId>
    <artifactId>hamcrest-optional</artifactId>
    <version>2.0.0</version>
    <scope>test</scope>
</dependency>

Die Bibliothek stellt vier Matcher zur Verfügung, mit denen Optionals geprüft werden können. Die Matcher isEmpty() und isPresent() prüfen, ob das Optional leer ist oder nicht. Die Matcher isPresentAndIs(Object) und isPresentAnd(Matcher) prüfen den Wert in einem gefüllten Optional.

assertThat(person.getDeath(), isEmpty());
assertThat(person.getBirth().getPlace(), both(isPresentAndIs(contains("Bielefeld")))));

Ob selbst gemacht oder aus der Bibliothek, die Hamcrest Matcher sind ein ausdruckstarkes Mittel für die Testformulierung.