
Passbird ist ein Referenzprojekt für diesen Blog. Auf dieser Seite beleuchte ich die Verwendung von AssertJ in Passbird. Die hier gezeigten Beispiele beziehen sich auf die Version Passbird 2.2.1. Durch die Weiterentwicklung der Software kann es passieren, dass aufgeführte Code-Ausschnitte in der aktuellen Version von Passbird in abgewandelter Form oder gar nicht mehr vorhanden sind.
Transformationen abprüfen
Mir ist bei einem Test wichtig, dass dieser nicht aufgrund eines Laufzeitfehlers fehlschlägt, sondern auf Basis einer unzutreffenden Annahme (Assertion). Darum schreibe ich Tests gerne null-safe.
In einem Test, der eine Transformation abprüft, also dass eine Variable mit einem Startwert A in einen Zielwert B übersetzt wird, teste ich gerne, dass diese Variable am Ende nicht nur den Wert B hat, sondern auch nicht mehr den Wert A. Das ist eine gute Möglichkeit zu verhindern, dass A und B den gleichen Wert haben und der Test damit keinen Sinn mehr macht. Das klingt zunächst einmal ungewöhnlich. Warum sollte dies passieren können? Die Erklärung ist, dass A und B ja nicht immer direkt im Test festgesetzt sein müssen. Wenn A den Wert einer Konstante aus einer TestData-Klasse erhält, kann der Wert der Konstante an anderer Stelle geändert werden und es bleibt dabei unter Umständen unbemerkt, dass damit ein Test seinen Sinn verliert. Der Wert könnte auch aus einem Faker kommen. Ein weiteres Beispiel wäre ein Default-Wert, der sich geändert hat. Wird z.B. ein Enum getestet, dass nur sehr wenige Ausprägungen hat und es ändert sich der Default, dann kann es schnell passieren, dass A und B auf die gleiche Enumkonstante referenzieren.
Lange Rede kurzer Sinn, abzutesten dass eine Variable am Ende nicht null
ist, den Zielwert angenommen hat und nicht mehr dem Startwert entspricht, hat aus meiner Sicht seine Berechtigung. Mit klassischen JUnit-Assertions würde ich dafür drei separate Statements schreiben. Das fluent interface von AssertJ erlaubt es mir die Aufrufe aneinanderzuhängen und je nach Länge des Gesamtausdrucks alles in eine Zeile zu schreiben, oder den Ausdruck auf mehrere Zeilen zu verteilen.
Der folgende Test prüft für die Domain Entity Password
ab, dass der Wert des Passwortes beim Aufruf der Methode update()
aktualisiert wird und nicht mehr dem vorherigen Wert entspricht.
@Test
void shouldUpdatePassword() {
// given
final var originalBytes = Bytes.of("password");
final var password = Password.create(originalBytes);
final var updatedBytes = Bytes.of("p4s5w0rD");
// when
password.update(updatedBytes);
final var actual = password.view();
// then
assertThat(actual).isNotNull()
.isEqualTo(updatedBytes)
.isNotEqualTo(originalBytes);
}
Numerische Vergleiche
AssertJ bietet viele sprechende Assertions für numerische Werte, von denen ich in Passbird auch Gebrauch mache. Ein klassisches Anti-Beispiel für einen gute lesbaren numerischen Vergleich ist für mich die Methode BigDecimal.compareTo()
. Die Methode liefert einen negativen oder positiven Wert oder null zurück, je nachdem ob der Vergleichswert in Relation zu this
größer, kleiner oder gleichwertig ist. Als jemand, der die Methode nur gelegentlich verwenden muss, muss ich jedes Mal wieder in die API-Dokumentation von BigDecimal
schauen um herauszufinden, ob ein negativer oder positiver Wert für einen größeren oder kleineren Vergleichswert gilt, oder ob es andersrum ist. Gut, dass die von AssertJ angebotenen Ausdrücke hier unmissverständlich sind.
Der folgende Test ist aus der Testsammlung des BytesComparator
und prüft ab, ob null
vor oder nach ein Bytes
mit Wert 1 einsortiert wird. Das Comparator-Interface teilt sein Schicksal mit der Methode BigDecimal.compareTo()
, auch hier muss ich jedes Mal in die Dokumentation gucken: Ist der erste Wert kleiner, so ist das Ergebnis negativ. Ist der erste Wert größer, so ist das Ergebnis positiv.
@Test
void shouldCompare_NullAndNonEmpty() {
// given
final Bytes bytes1 = null;
final Bytes bytes2 = Bytes.of("1");
// when / then
assertThat(comparator.compare(bytes1, bytes2)).isNegative();
assertThat(comparator.compare(bytes2, bytes1)).isPositive();
}
Strings untersuchen
AssertJ bietet bekannte String-Methoden wie contains()
und startsWith()
als Assertions an. Einen großen Mehrwert hinsichtlich der Lesbarkeit bietet dies meiner Meinung nach in Kombination mit der Möglichkeit, mittels extracting()
in Strukturen einzutauchen und dank des fluent interfaces dabei die Knoten auf null
zu überprüfen, um den Test nicht mit einem Laufzeitfehler unerwartet beenden zu müssen.
Der folgende Test, dass nach Aktualisierung eines Passwortes eine entsprechende Meldung an die Benutzerschnittstelle erfolgt, in der der Bezeichner (key) des Passwortes enthalten ist. Die Meldung wird über einen ArgumentCaptor
abgefangen. Der abgefangene Wert entspricht einem Output
, der ein Bytes
kapselt, welches über Bytes.asString()
als Text interpretiert werden kann. Über contains()
von AssertJ wird dann sichergestellt, dass der Key
des aktualisierten PasswordEntry
in dem Text enthalten ist:
@Test
void shouldProcessPasswordEntryUpdated() {
// given
final var givenPasswordEntry = PasswordEntryFaker.faker().fakePasswordEntry().fake();
final var passwordEntryUpdated = new PasswordEntryUpdated(givenPasswordEntry);
final var expectedBytes = Bytes.of("expected key");
given(cryptoProvider.decrypt(givenPasswordEntry.viewKey())).willReturn(Try.success(expectedBytes));
// when
pwMan3EventRegistry.register(passwordEntryUpdated);
pwMan3EventRegistry.processEvents();
// then
then(userInterfaceAdapterPort).should().send(captor.capture());
assertThat(captor.getValue()).isNotNull()
.extracting(Output::getBytes).isNotNull()
.extracting(Bytes::asString).isNotNull()
.asString().contains(expectedBytes.asString());
}
Für noch flexiblere inhaltliche Abgleiche unterstützt AssertJ auch reguläre Ausdrücke. Diese verwende ich um generierte Passwörter auf die definierten Rahmenbedingungen hin zu testen. Ein von Passbird generiertes Passwort muss mindestens einen Groß- und Kleinbuchstaben und eine Ziffer enthalten, je nach Konfiguration muss es auch ein Sonderzeichen enthalten oder darf dies explizit nicht, und muss konfigurationsabhängig eine definierte Länge besitzen. Da hier unweigerlich Zufallswerte im Spiel sind, werden im Test eine größere Zahl an Passwörtern generiert und auf die Rahmenbedingungen hin überprüft.
Der folgende Test demonstriert die Abprüfung auf Kleinbuchstaben im Passwort über den regulären Ausdruck .*[a-z].*
. Die Methode assertManyTimes()
ist selbstgeschrieben und erlaubt es einen Aufruf viele Male zu testen, in dem Fall die Erzeugung eines neuen Passwortes:
@Test
void shouldIncludeLowercase() {
// given
final var passwordRequirements = PasswordRequirementsFaker.faker()
.fakePasswordRequirements().fake();
// when / then
assertManyTimes(() ->
assertThat(passwordProvider.createNewPassword(passwordRequirements).asString()
.matches(".*[a-z].*")).isTrue());
}
private void assertManyTimes(final Supplier supplier) {
IntStream.range(0, 10).forEach(i -> supplier.get());
}
Strukturen auslesen
AssertJ kann auch mit Optionals
umgehen. Um zu verifizieren, dass ein Optional nicht leer ist und dann auf dem im Optional enthaltenen Objekt weiterzuarbeiten, können die Methoden isNotEmpty()
und get()
verwendet werden. Die selbstgeschriebene Methode assertThatKeyExistsWithPassword()
wird von mehreren Tests in Passbird verwendet und überprüft, dass ein gegebener PasswordEntry
im PasswordEntryRepository
existiert. Die Repository-Methode find()
liefert ein Optional zurück, welches entweder das gefundene Objekt beinhaltet oder leer ist, wenn es keinen Treffer gibt:
private void assertThatKeyExistsWithPassword(final Bytes keyBytes, final Bytes passwordBytes) {
assertThat(passwordEntryRepository.find(keyBytes))
.isNotEmpty().get()
.extracting(PasswordEntry::viewPassword).isNotNull()
.isEqualTo(passwordBytes);
}
In einigen Fällen gehen durch den Aufruf von extracting() Kontextinformationen verloren. Dann hat man zwei Möglichkeiten. Entweder schreibt man eine separate Assertion, oder man versucht das Problem über einen Cast zu adressieren. Der folgende Test prüft, dass ein Domain Event registiert wird und weist AssertJ über den Aufruf von asList()
an, dass getDomainEvents()
als Liste interpretiert werden darf und es damit wiederum möglich ist, über containsExactly()
zu prüfen, dass die Liste genau das erwartete Objekt enthält:
@Test
void shouldRegisterDomainEvent() {
// given
final var domainEvent = new DomainEvent(){};
final var aggregate = setupAggregate();
// when
aggregate.registerDomainEvent(domainEvent);
// then
assertThat(aggregate)
.extracting(AggregateRoot::getDomainEvents).isNotNull()
.asList().containsExactly(domainEvent);
}
Die Methode map()
erlaubt es, null-safe durch eine Struktur zu navigieren. Der folgende Integrationstest überprüft das erfolgreiche Schreiben und Auslesen einer Konfigurationsdatei beim ersten Start des Programmes und die Verwendung des als Java-System-Property gesetzten Konfigurationsverzeichnisses. Die Konfiguration liegt als Optional vor. Der auszulesende Wert befindet sich tief in der Konfigurationsstruktur unter getAdapter().getKeyStore().getLocation()
:
@Test
void shouldUseConfigurationFile_Roundtrip() {
// first load template if physical files does not exist
System.setProperty(CONFIGURATION_SYSTEM_PROPERTY, tempConfigurationDirectory);
final var configuration = configurationFactory.loadConfiguration();
assertThat(configuration.isTemplate()).isTrue();
// now persist configuration to file system
new ConfigurationSyncService(configuration, systemOperation).sync(tempConfigurationDirectory);
// now load the persisted configuration and ensure the given configuration directory has been persisted too
final var loadedConfiguration = configurationFactory.loadConfiguration();
assertThat(Optional.ofNullable(loadedConfiguration))
.map(ReadableConfiguration::getAdapter)
.map(ReadableConfiguration.Adapter::getKeyStore)
.map(ReadableConfiguration.KeyStore::getLocation)
.isPresent().get().asString()
.isEqualTo(tempConfigurationDirectory);
assertThat(loadedConfiguration).isNotNull()
.extracting(ReadableConfiguration::isTemplate)
.isEqualTo(false);
}
Eine mächtiges Werkzeug ist die Extrahierung von Feldern anhand ihrer Namen. Mein Anwendungsbeispiel dafür sind geladene Interface-Implementierungen im Kontext von Dependency Injection. Im Fall von Passbird kommt Guice für Dependency Injection zum Einsatz. Im Falle von Spring würde man verschiedene Klassen das gleiche Interface implementieren lassen und zum Beispiel als @Component deklarieren. Lädt man nun eine Liste der Interfaces per Dependency Injection, würde das Spring-Framework dafür sorgen, dass diese Liste alle deklarierten Implementierungen enthält. Fehlt eine @Component-Deklaration, so wird diese Implementierung nicht geladen und damit auch nicht verwendet. Gerade bei Operationen über alle geladenen Implementierungen finde ich es wichtig diesen Fall abzutesten, da die Ursache für daraus resultierende Fehler nicht unbedingt auf den ersten Blick ersichtlich ist und die Analyse viel Zeit kosten kann. Man stelle sich z.B. vor, dass in Produktion plötzlich eine Schnittstellendatei nicht mehr geschrieben wird. Die Ursachen dafür können sicherlich vielschichtig sein. Man schaut vielleicht zuerst in die Logs, findet aber keine Fehler. Dann probiert man verschiedene Sachen aus, ohne Erfolg. Nachdem viel Zeit vergangen ist bemerkt man irgendwann, dass die Implementierung nicht mehr vom Framework geladen wird, weil irgendwann versehentlich die dafür notwendige unscheinbare Deklaration entfernt worden ist. Reguläre Unit-Tests entdecken soetwas in der Regel nicht, im Falle von Spring würde man einen Integrationstest unter Verwendung der SpringExtension (wenn JUnit 5 eingesetzt wird) schreiben müssen. Ein Test ist aber schnell geschrieben und rentiert sich schon, wenn man damit auch nur einmal dieses Fehlerszenario verhindern kann.
Der folgende Test, dass der ApplicationEventHandler und DomainEventHandler über Guice geladen werden. Mittels extracting(String)
mappt AssertJ die Objekte in der Liste getEventHandlers()
auf ihre jeweilige class
und gleicht diese mit den erwarteten Klassen ab. Da die Reihenfolge der geladenen Instanzen keine Rolle spielt und auch nirgends spezifiziert wird, bietet sich dafür die Assertion containsExactlyInAnyOrder()
an:
@Test
void shouldResolveAllDependencies() {
// given / when
final var actual = Guice.createInjector(Modules.override(new ApplicationModule()).with(new PwMan3TestModule()))
.getInstance(PwMan3TestMain.class);
// then
assertThat(actual).isNotNull();
assertAllPropertiesAreBound(actual);
assertThatAllCommandHandlersAreBound(actual);
assertThatAllEventHandlersAreBound(actual);
}
private static void assertThatAllEventHandlersAreBound(PwMan3TestMain pwMan3TestMain) {
assertThat(pwMan3TestMain.getEventHandlers()).isNotNull()
.extracting("class")
.containsExactlyInAnyOrder(
ApplicationEventHandler.class,
DomainEventHandler.class);
}
Der Test stellt sicher, dass viele verschiedene Abhängigkeiten geladen werden. Da der Fokus hier auf extracting("class")
liegt, habe ich der Übersicht halber die anderen selbstgeschriebenen assert-Methoden nicht eingebettet. Da der Fokus auf Guice liegt und es die Lesbarkeit verbessert, habe ich jedoch den tatsächlichen Test mit aufgeführt, auch wenn sich der wesentliche Teil in der aufgeführten selbstgeschriebenen assert-Methode abspielt.