Ab die Post (Teil 2)

“Walking on water and developing software from a specification are easy if both are frozen.”

Edward V. Berard

Spezifikationen sind wie alle Projektanforderungen stetiger Veränderung unterworfen. Dies liegt an immer neuen Marktbedingungen, unerwarteten Kundenwünschen aber auch an den persönlichen Vorlieben, Abneigungen und Kenntnissen der Entwickler und den Möglichkeiten der verwendeten Bibliotheken.

Die Software verhält sich wie eine organische Struktur, die den Raum zwischen den Anforderungen nutzt. Die schwere Aufgabe der Entwickler ist es, diese Struktur flexible zu halten, damit sie bei allen zukünftigen Veränderungen Schritt halten kann. Versagen die Entwickler, dann verkrustet der Code an verschiedenen Stellen, die Veränderung wird immer teurer und eine neue Legacy Software ist entstanden.

Im vorherigen Beitrag zu JavaMail API wurde die Bibliothek GreenMail vorgestellt. Mit dieser Bibliothek ist es möglich, verschiedene E-Mail Server zu simulieren. Da keine überzeugende JUnit 5 Unterstützung existiert, lag die Idee nahe, eine eigene JUnit Extension zu schreiben. Während der Implementierung zeigten sich erste Ideen als unnütz, das Verständnis der GreenMail API verbesserte sich und die eigene Art zu Testen wirkte sich natürlich auf die Gestaltung der Erweiterung aus.

Mit der eigenen GreenMail Extension sind nun JUnit Test Klassen der folgenden Form möglich.

@ExtendWith(GreenMailExtension.class)
class MailBoxTest {

  @SmtpTest
  void testSmtpFindAllWithCount(Session mailSession, GreenMailBox mailBox)
      throws MessagingException, ExecutionException, InterruptedException {
    TreeMailService service service = new TreeMailService(mailSession)
    service.sendTreeMail("info@ahnen.de");
    service.sendTreeMail("admin@vorfahren.org");

    List<Mail> receivedMessages = mailBox.findAll(2).get();
    assertThat(receivedMessages,
        hasItems(
            allOf(
                hasTo(hasItem("info@ahnen.de")),
                hasFrom(),
                hasSubject("Test"),
                hasBCC(hasItem("quality@schegge.de"))),
            allOf(hasTo(hasItem("admin@vorfahren.org")),
                hasSubject(),
                hasFrom(endsWith("schegge.de")),
                hasBCC(hasItem(startsWith("quality"))))));
  }
}

Die Test Klasse muss mit @ExtendWith(GreenMailExtension.class) annotiert sein, damit die eigene Extension geladen wird. Statt @ExtendsWith kann aber auch die Annotation @GreenMailTest oder @GreenMailSmtpTest verwendet werden. Diese Annotationen sorgen auch für das Laden der Extension, weil sie selbst mit @ExtendsWith annotiert sind.

In der testSmtpFindAllWithCount Methode wird zuerst der zu testende Service mit einer JavaMail Session initialisiert. Die Session Instanz wird von der Extension in den Methoden-Parameter session injected.

@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
  Store store = extensionContext.getStore(GREENMAIL);
  GreenMail greenMail = store.get(SERVER, GreenMail.class);
  if (GreenMailBox.class == parameterContext.getParameter().getType()) {
    return new GreenMailBox(greenMail);
  }
  Properties properties = greenMail.getSmtp().getServerSetup()
      .configureJavaMailSessionProperties(new Properties(), store.get(DEBUG, boolean.class));
  if (Session.class == parameterContext.getParameter().getType()) {
    return Session.getDefaultInstance(properties);
  }
  return properties;
}

Mit der resolveParameter Methode der Extension wird je nach Typ eines Parameters entschieden, wie damit zu verfahren ist. Bei einem Parameter vom Typ GreenMailBox wird ein Wrapper für die aktuelle GreenMail Server Instanz erzeugt. Für Parameter vom Type Properties und Session werden die JavaMail Session Properties der aktuellen GreenMail Server Instanz genutzt.

Mehr zu Thema ParameterResolver ist in den Beiträgen Dependency Injection mit ParameterResolver in JUnit 5 und Zufallswerte in JUnit 5 zu lesen.

Jede Test-Methode kann mit @GreenMailSettings annotiert werden. Über diese Annotation kann der im Test genutzte Mail-Server definiert werden und der JavaMail Debug-Modus aktiviert werden.

@Test
@GreenMailSettings(setup = GreenMailSetup.SMTP, debug = true)
void testTreeImportNotificationEmail() {
}

Dieser Test verwendet den vordefinierten SMTP Test Server auf Port 3025 und aktiviert den Debug-Modus. Um den Schreibaufwand für diesen Fall zu verkürzen, existiert eine spezielle Annotation @SmtpTest dafür.

@SmtpTest
void testTreeImportNotificationEmail() {
}

Die Extension liest die @GreenMailSettings Annotation vor jeder Test Methode aus und initialisiert den Server entsprechend. Fehlt die Annotation an der Methode, dann wird geprüft, ob eine entsprechende Annotation an der Klasse zu finden ist. Fehlt die Annotation gänzlich, dann wird ein Standardwert verwendet.

private Optional<GreenMailSettings> retrieveAnnotation(final ExtensionContext context) {
  ExtensionContext currentContext = context;
  do {
    Optional<GreenMailSettings> annotation = findAnnotation(currentContext.getElement(), GreenMailSettings.class);
    if (annotation.isPresent()) {
      return annotation;
    }
    currentContext = currentContext.getParent().orElse(context.getRoot());
  } while (currentContext != context.getRoot());
  return Optional.empty();
}

Nun fehlt nur noch das Starten und Stoppen des GreenMail Servers vor dem jeweiligen Unit-Test. Dies geschieht in den Methoden

@Override
public void beforeEach(ExtensionContext context) {
  Optional<GreenMailSettings> greenMailSettings = retrieveAnnotation(context);
  ServerSetup[] setups = greenMailSettings.map(GreenMailSettings::setup).stream().flatMap(Arrays::stream).distinct()
        .map(GreenMailSetup::getServerSetup).toArray(ServerSetup[]::new);
    boolean debug = greenMailSettings.map(GreenMailSettings::debug).orElse(false);

  GreenMail greenMail = new GreenMail(setups.length == 0 ? ServerSetupTest.ALL : setups);
  greenMail.start();
  context.getStore(GREENMAIL).put(SERVER, greenMail);
  context.getStore(GREENMAIL).put(DEBUG, debug);
  }

@Override
public void afterEach(ExtensionContext context) {
  context.getStore(GREENMAIL).get(SERVER, GreenMail.class).stop();
}

In der beforeEach Methode, werden die Konfigurationsen aus den Annotationen gelesen und ein GreenMail Server damit gestartet. Damit die Extension stateless bleibt, wird der Server im Junit 5 Store gespeichert und zum Stoppen in der Methode afterEach daraus ausgelesen.

An dieser Stelle ist die Implementierung noch nicht ganz sauber, da der Server pro Port gespeichert werden sollte. Parallel ausgeführte Tests würden sich sonst gegenseitig stören.

Veränderung hat die Verifizierungen innerhalb der Tests erfahren. Damit überprüft werden kann, welche E-Mails ausgeliefert wurden, kann der GreenMail Server über eine Instanz vom Typ GreenMailBox befragt werden. Hier existieren nur noch drei Methoden um E-Mails vom GreenMail Server abzuholen. findOne(), findAll() und findAll(count).

Alle drei Methoden liefern ein CompletableFuture zurück, da das Versenden der E-Mails an den Server asynchron erfolgen kann.

public CompletableFuture<Mail> findOne() {
  return findAll(1).thenApply(list -> list.get(list.size() - 1));
}

Die Methode findOne ist ein Spezialfall der Methode findAll(int count). Sie wartet auf genau eine E-Mail und liefert das letzte Element aus der Liste der vorhandenen E-Mails zurück. Sie ist für einfache Unit Tests gedacht, bei denen nur eine einzelne Email verschickt werden soll.

public CompletableFuture<List<Mail>> findAll(int count) {
  CompletableFuture<List<Mail>> completableFuture = new CompletableFuture<>();
  executor.submit(() -> {
    if (!greenMail.waitForIncomingEmail(count)) {
        completableFuture.completeExceptionally(new IllegalArgumentException("no incoming mails!"));
      return null;
    }
    List<Mail> collect = Stream.of(greenMail.getReceivedMessages()).map(Mail::new).collect(toList());
    completableFuture.complete(collect);
    return null;
  });
  return completableFuture;
}

Die findAll(count) Methode wartet darauf, dass eine bestimmte Anzahl (count) von E-Mails, an den Server gesendet wurden. Dabei bedient es sich einer Methode aus dem GreenMail Framework und erbt deren Eigenheiten. Die Anzahl der E-Mails bezieht auch die E-Mails ein, die sich beim Aufruf der Methode schon im Speicher des Servers befinden. Möchte man sich nicht auf diese Methode verlassen, dann kann man mit der Methode findAll(), alle aktuell vorhandenen E-Mails des Servers abrufen.

public CompletableFuture<List<Mail>> findAll() {
  return CompletableFuture
        .completedFuture(Stream.of(greenMail.getReceivedMessages()).map(Mail::new).collect(toList()));
}

Diese Methode könnte zwar einfach eine Liste von Mail Instanzen zurückliefern. Damit sie aber den gleichen Rückgabewert wie ihre Schwestern besitzt, befüllt sie ein CompletableFuture mit der completedFuture Methode.

Um nun zu verifizieren, ob die versendeten E-Mails dem entsprechen, was sich der Entwickler vorgenommen hat, stehen einige eigene Hamcrest-Matcher bereit. Die Matcher arbeiten alle auf Mail Instanzen, da die Klasse MimeMessage zu unhandlich ist.

public String getFrom() {
  return stream(secureMessagingMethod(message::getFrom)).findFirst().orElseThrow(MailingException::new);
}

public List<String> getTo() {
  return recipients(RecipientType.TO).collect(toList());
}

public List<String> getCc() {
  return recipients(RecipientType.CC).collect(toList());
}

public List<String> getBcc() {
  return recipients(RecipientType.BCC).collect(toList());
}

private Stream<String> recipients(RecipientType type) {
  return stream(secureMessagingMethod(() -> message.getRecipients(type)));
}

Die Klasse Mail liefert Listen anstelle von Arrays und bei Attributen, die für E-Mails nicht gesetzt sein müssen, ein Antwort vom Typ Optional. Die Methoden der Klasse MimeMessage werfen alle eine Checked-Exception, die hier durch die Methode secureMessagingMethod entschärft wird.

private interface MessagingSupplier<T> {
  T get() throws MessagingException;
}

private <T> T secureMessagingMethod(MessagingSupplier<T> supplier) {
  try {
    return supplier.get();
  } catch (MessagingException e) {
    throw new MailingException(e);
  }
}

Die Methode secureMessagingMethod akzeptiert als Parameter Getter Methoden die eine MessagingException werfen. Dies sind die Getter-Methoden der Klasse MimeMessage.

Die Klasse Mail beschäftigt sich mit versendete Emails, daher gibt es zwei Besonderheiten zu beachten.

Die Methode getFrom liefert einen String zurück, da der Absender gesetzt sein muss und es nur einen Absender dieser E-Mail gibt. Die Methode getBcc liefert immer eine leere Liste zurück, weil die BCC Empfänger nicht in den Mime-Messages gespeichert sind. Hier sind nur die TO und CC Empfänger zu finden. Auf die Anzahl der BCC Empfänger kann nur über die Anzahl E-Mails mit der selben Message-Id geschlossen werden.

Die Hamcrest-Matcher basieren auf FeatureMatcher, die verschiedene Attribute der Mail Klasse prüfen. Hier exemplarisch die beiden Matcher für den Absender der E-Mail.

public static Matcher<Mail> hasFrom(final Matcher<String> fromMatcher) {
  return new FeatureMatcher<>(fromMatcher, "from", "from") {
    @Override
    protected String featureValueOf(final Mail actual) {
      return actual.getFrom();
    }
  };
}

public static Matcher<Mail> hasFrom(final String from) {
  return hasFrom(Matchers.equalTo(from));
}

Damit ist die Java 5 Extension für GreenMail auch schon implementiert und kann in eigenen Projekten genutzt werden. Die Sourcen liegen zum Beitrag liegen auf GitLab für Interessierte bereit.