“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.