
Passbird ist ein Referenzprojekt für diesen Blog. Auf dieser Seite beleuchte ich die Verwendung von Fakern 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.
In der aktuellen Version von Passbird gibt es keine Fake-Objekte. Derartige Objekte finden vor allem dann Anwendung, wenn es eine Komplexe Domäne gibt und bestimmte Zielzustände nicht ohne Weiteres erreicht werden können. Die Domäne von Passbird ist zurzeit recht einfach und erfordert daher keine Fake-Objekte. Sollte sich dies in Zukunft ändern, werde ich diese Seite um entsprechende Fallbeispiele erweitern.
Faker für Passwörter
Ein PasswordEntry
repräsentiert ein Passwort in Passbird. Bestandteil eines PasswordEntry
sind das eigentliche inhaltliche Passwort und der Identifizierer, in der Domäne als Key
bezeichnet. Beide liegen als Bytes
vor, eine Struktur, die ein byte
-Array kapselt.
Für viele Tests ist es unerheblich, wie Key
und Password
eines PasswordEntry
aussehen. Dennoch wird ein PasswordEntry
als Referenz benötigt, um Verhalten abzutesten. Ohne Faker gibt es eine Vielzahl von Möglichkeiten, einen PasswordEntry
im Test zu initialisieren:
PasswordEntry.create(null, null)
PasswordEntry.create(Bytes.emptyBytes(), Bytes.emptyBytes())
PasswordEntry.create(Bytes.of("key"), Bytes.of("password"))
- Definition einer Konstante in einer
TestData
-Klasse
Keine der Varianten finde ich besonders ansprechend. In den ersten drei Fällen kommt es oft zu einer bunten Mischung verschiedener Varianten, je nachdem wer die Tests gerade schreibt. Es ist darüber hinaus nicht klar, ob z.B. mit dem jeweiligen Aufruf etwas bestimmtes bezweckt wird, oder ob es lediglich für den Test unerheblich ist. Vielleicht hat sich der Entwickler ja was dabei gedacht, an create()
zweimal null
zu übergeben? Besonders kritisch ist es, wenn die Domäne wächst und null
plötzlich bei der Instanzierung eines PasswordEntry
nicht mehr übergeben werden darf. In diesem Fall müssen mitunter dutzende Tests umgeschrieben werden, eine zeitintensive Tätigkeit, die das Risiko birgt, weitere Fehler einzuführen, und keinen Mehrwert für das Produkt bringt. Im schlimmsten Fall werden Kompromisse bei der Gestaltung der Domäne in Kauf genommen, damit keine Tests umgeschrieben werden müssen.
Den Nachteil der TestData
-Klasse sehe ich darin, dass sie oftmals eine bunte Mischung verschiedenster Konstanten beinhaltet und sie nicht alle Entwickler konsequent verwenden. Mit der Zeit entstehen unter Umständen mehrere TestData
-Klassen, die sich nicht wirklich gut voneinander abgrenzen. Einige Entwickler verwenden die darin definierten Konstanten, andere nicht.
Mit Faker sieht ein Test in Passbird wie folgt aus:
@Test
void shouldDeregisterAggregate() {
// given
final var aggregate = PasswordEntryFaker.faker().fakePasswordEntry().fake();
final var domainEvent1 = new PasswordEntryCreated(aggregate);
aggregate.registerDomainEvent(domainEvent1);
pwMan3EventRegistry.register(aggregate);
// when
pwMan3EventRegistry.deregister(aggregate);
pwMan3EventRegistry.processEvents();
// then
then(eventBus).shouldHaveNoInteractions();
assertThat(aggregate.getDomainEvents()).contains(domainEvent1);
}
Es ist eindeutig, dass ein PasswordEntry
benötigt wird, Key
und Password
aber keine Rolle für den Testfall spielen. Sollten Key
und Password
, die per Methode fakePasswordEntry()
mit einem Default belegt werden, irgendwann nicht mehr den Anforderungen der Domäne entsprechen, können sie an zentraler Stelle geändert werden.
Im folgenden Test spielt der Key
eines PasswordEntry
eine Rolle. Es ist aber unerheblich, welchen Wert das Password
hat. Ein Faker erlaubt es nach dem Builder-Pattern individuelle Merkmale des Objektes zu überschreiben. Damit wird hier ein spezifischer Key
für den PasswordEntry
gesetzt.
@Test
void shouldFindPasswordEntry() {
// given
final var givenKeyBytes = Bytes.of("target");
final var expectedPasswordEntry = PasswordEntryFaker.faker()
.fakePasswordEntry()
.withKeyBytes(givenKeyBytes).fake();
final var otherPasswordEntry1 = PasswordEntryFaker.faker().fakePasswordEntry().fake();
final var otherPasswordEntry2 = PasswordEntryFaker.faker().fakePasswordEntry().fake();
PasswordStoreAdapterPortFaker.faker()
.forInstance(passwordStoreAdapterPort)
.withThesePasswordEntries(otherPasswordEntry1, expectedPasswordEntry, otherPasswordEntry2).fake();
// when
final var actual = repository.find(givenKeyBytes);
// then
assertThat(actual).isNotEmpty().contains(expectedPasswordEntry);
}
Faker für den Password-Service
Der PasswordService
ist ein zentraler Domain Service in Passbird. Über ihn werden Passwörter verwaltet, d.h. angelegt, abgerufen, geändert oder gelöscht. Der Password-Service stellt außerdem die Ver- und Entschlüsselung der Passwörter beim Zugriff auf das Repository sicher.
Viele Befehle in Passbird benötigen den PasswordService
in irgendeiner Form, daher ist es naheliegend ihn über einen Faker bereitzustellen, um nicht in jedem Test individuell verschiedene Methoden dieses Services mocken zu müssen. Auch wird durch einen Faker hier wieder sichergestellt, dass Designänderungen am PasswordService
zentral im Faker berücksichtigt werden können, anstatt dass dutzende Tests umgeschrieben werden müssen.
Der Faker zu diesem sowieso zu weiteren Services unterscheidet sich vom Faker eines Domain Models: Da Services in Passbird über Dependency Injection geladen werden, stellt der Faker keine eigene Instanz zur Verfügung, sondern operiert auf einer bestehenden Instanz. Ein wesentliches Merkmal des PasswordService
ist der kontrollierte Zugang zum Repository. Mit der Möglichkeit, eine Liste von PasswordEntry
-Objekten simuliert ins Repository zu laden, kann der PasswordServiceFaker
viele Testfälle bedienen, ohne dass eine große Menge an Setup-Code in den jeweiligen Tests erforderlich ist.
Das Verhalten des vom Faker verwalteten PasswordService
wird über Mockito definiert. Auch wenn der Faker nach außen ein ziemlich schlankes Interface, bestehend aus lediglich fünf Methoden, aufweist, so steckt in ihm eine Menge Logik, die in Zukunft sicherlich auch mal infrage gestellt werden kann:
public PasswordService fake() {
lenient().when(passwordService.putPasswordEntries(any())).thenReturn(Try.success(null));
lenient().when(passwordService.challengeAlias(any(Bytes.class))).thenReturn(Try.success(null));
if (invalidAlias != null) {
lenient().when(passwordService.challengeAlias(invalidAlias))
.thenReturn(Try.failure(new InvalidKeyException(invalidAlias)));
}
lenient().when(passwordService.findAllKeys())
.thenReturn(Try.of(() -> passwordEntries.stream().map(PasswordEntry::viewKey)));
lenient().when(passwordService.entryExists(any(Bytes.class), any(PasswordService.EntryNotExistsAction.class)))
.thenReturn(Try.of(() -> false));
lenient().when(passwordService.putPasswordEntry(any(Bytes.class), any(Bytes.class)))
.thenReturn(Try.success(null));
lenient().when(passwordService.renamePasswordEntry(any(Bytes.class), any(Bytes.class)))
.thenReturn(Try.success(null));
passwordEntries.forEach(passwordEntry -> {
lenient().when(passwordService.viewPassword(passwordEntry.viewKey()))
.thenReturn(Optional.of(Try.of(passwordEntry::viewPassword)));
lenient().when(passwordService.entryExists(
eq(passwordEntry.viewKey()),
any(PasswordService.EntryNotExistsAction.class))
).thenReturn(Try.of(() -> true));
lenient().when(passwordService.discardPasswordEntry(passwordEntry.viewKey()))
.thenReturn(Try.success(null));
});
return passwordService;
}
So kompliziert die Interna der fake()
-Methode auch sind: Die Tests, die einen PasswordService
benötigen, profitieren von dieser Funktionsvielfalt ungemein und sind selbst dank der Macht des Fakers schlank und übersichtlich.
Der folgende Test zeigt die Rolle des PasswordServiceFaker
im Integrationstest des GetCommand
. Die Instanz passwordService
wird über @Mock
geladen, da der Service über Dependency Injection in die Instanz des GetCommand
gelangt. Über die Faker-Methode withPasswordEntries()
wird ein einziger PasswordEntry
in den Service geladen, um zu demonstrieren, dass der GetCommand
das darin enthaltene Passwort in die Zwischenablage kopiert:
@Test
void shouldHandleGetCommand() {
// given
final var args = "key";
final var bytes = Bytes.of("g" + args);
final var reference = bytes.copy();
final var expectedPassword = Bytes.of("value");
PasswordServiceFaker.faker()
.forInstance(passwordService)
.withPasswordEntries(PasswordEntryFaker.faker()
.fakePasswordEntry()
.withKeyBytes(Bytes.of(args))
.withPasswordBytes(expectedPassword).fake()).fake();
// when
assertThat(bytes).isEqualTo(reference);
inputHandler.handleInput(Input.of(bytes));
// then
then(clipboardAdapterPort).should().post(eq(Output.of(expectedPassword)));
then(userInterfaceAdapterPort).should().send(any(Output.class));
assertThat(bytes).isNotEqualTo(reference);
}
Faker für die Konfiguration
Die Konfiguration ist für den lesenden Zugriff in Passbird per Dependency Injection global verfügbar. Über die Konfiguration wird beispielsweise gesteuert, welche Länge auto-generierte Passwörter haben, ob die Zwischenablage nach einer definierten Zeit automatisch geleert wird, nachdem ein Passwort in dieselbige kopiert worden ist und ob bestimmte Aktionen wie ein Löschen oder Überschreiben eines Passwortes explizit vom Anwender bestätigt werden müssen.
Der Zugriff auf die Konfiguration findet daher in diversen Komponenten von Passbird statt und wirkt sich auf den Programmfluss aus. Dementsprechend hilfreich ist es, wenn sich mit wenig Aufwand die Konfiguration in Testfällen simulieren lässt.
Die Faker-Klasse ähnelt dem PasswordServiceFaker
, hat aber nicht die gleiche Komplexität in der Methode fake()
. Stattdessen werden viele einzelne Attribute gesammelt und am Ende zu einem gemockten Configuration
-Objekt zusammengebaut. Bei Booleans finde ich grundsätzlicher angenehmer im Test, wenn der Boolean nicht direkt an den Faker übergeben wird, sondern es für beide Varianten eine sprechende Methode gibt. Der folgende Code zeigt, wie über den Faker das Flag isSecureInput
gesetzt wird:
public ConfigurationFaker withSecureInputEnabled() {
this.isSecureInput = true;
return this;
}
public ConfigurationFaker withSecureInputDisabled() {
this.isSecureInput = false;
return this;
}
Der folgende Test zeigt, wie der ConfigurationFaker
verwendet wird. Der Test prüft ab, dass Benutzereingaben, die über System.console().readPassword()
empfangen werden, auch wie erwartet ankommen. Der Aufruf von System.console()
wird über einen eigenen Faker gemockt. Die Konsole ist Bestandteil des JDK und der Aufruf readPassword()
führt zu einer verdeckten Benutzereingabe, bei der die eingegebenen Zeichen nicht sichtbar sind und nicht als String, sondern als char
-Array zurückgegeben werden.
@Test
void shouldReceiveInputSecurely() {
// given
final var givenInput = "hello world";
SystemOperationFaker.faker()
.forInstance(systemOperation)
.withPasswordFromConsole(givenInput.toCharArray()).fake();
ConfigurationFaker.faker()
.forInstance(configuration)
.withSecureInputEnabled().fake();
// when
final var actual = commandLineInterfaceService.receiveSecurely();
// then
assertThat(actual.isSuccess()).isTrue();
assertThat(actual.get()).isNotNull()
.extracting(Input::getBytes).isNotNull()
.extracting(Bytes::asString).isNotNull()
.isEqualTo(givenInput);
}
Faker für die Benutzerinteraktion
Die Benutzerinteraktion wird über das Interface UserInterfaceAdapterPort
abstrahiert. Über die Methoden send()
und receive()
werden Inhalte über die Benutzerschnittstelle ausgegeben bzw. von dieser empfangen. Da einige Commands wie etwa das CustomSetCommand
und das RenameCommand
weitere Eingaben vom Benutzer erwarten, lässt sich der Programmfluss des Commands gut testen, wenn die Benutzerinteraktion simuliert werden kann. Der Faker verwendet dafür Mockito Answers:
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class UserInterfaceAdapterPortFaker {
private final AtomicInteger inputCount = new AtomicInteger(0);
private final AtomicInteger secureInputCount = new AtomicInteger(0);
private final List<Input> inputList = new ArrayList<>();
private final List<Input> secureInputList = new ArrayList<>();
private UserInterfaceAdapterPort userInterfaceAdapterPort;
public static UserInterfaceAdapterPortFaker faker() {
return new UserInterfaceAdapterPortFaker();
}
public UserInterfaceAdapterPortFaker forInstance(final UserInterfaceAdapterPort userInterfaceAdapterPort) {
this.userInterfaceAdapterPort = userInterfaceAdapterPort;
return this;
}
public UserInterfaceAdapterPortFaker withReceiveConfirmation(final boolean confirmed) {
given(userInterfaceAdapterPort.receiveConfirmation(any(Output.class))).willReturn(confirmed);
return this;
}
public UserInterfaceAdapterPortFaker withTheseInputs(final Input... inputs) {
inputList.addAll(Arrays.asList(inputs));
return this;
}
public UserInterfaceAdapterPortFaker withTheseSecureInputs(final Input... inputs) {
secureInputList.addAll(Arrays.asList(inputs));
return this;
}
private void givenReceivedInput() {
lenient().when(userInterfaceAdapterPort.receive(any(Output.class))).thenAnswer(
invocation -> Try.of(() -> inputList.get(inputCount.getAndIncrement())));
lenient().when(userInterfaceAdapterPort.receive()).thenAnswer(
invocation -> Try.of(() -> inputList.get(inputCount.getAndIncrement())));
}
private void givenReceivedSecureInput() {
lenient().when(userInterfaceAdapterPort.receiveSecurely(any(Output.class))).thenAnswer(
invocation -> Try.of(() -> secureInputList.get(secureInputCount.getAndIncrement())));
lenient().when(userInterfaceAdapterPort.receiveSecurely()).thenAnswer(
invocation -> Try.of(() -> secureInputList.get(secureInputCount.getAndIncrement())));
}
public UserInterfaceAdapterPort fake() {
givenReceivedInput();
givenReceivedSecureInput();
return userInterfaceAdapterPort;
}
}
Der Faker unterstützt damit auch mehrfache aufeinanderfolgende Eingaben. So kann abgetestet werden, dass Passbird startet, wenn das Masterpasswort zweimal falsch und dann richtig eingegeben wird, oder auch dass Passbird sich nach drei erfolglosen Authentisierungsversuchen beendet. Ersteres wird in folgendem Test unter Verwendung des UserInterfaceAdapterPortFaker
abgeprüft:
@Test
void shouldCreateCryptoProvider_On3rdPasswordInputAttempt() {
// given
final var incorrectPassword = Input.of(Bytes.of("letmeout"));
final var correctPassword = Input.of(Bytes.of("letmein"));
final var keyStoreDirectory = "tmp";
final var keyStoreFilePath = PathFaker.faker().fakePath().fake();
final var keyStoreDirPath = PathFaker.faker()
.fakePath()
.withPathResolvingTo(keyStoreFilePath, ReadableConfiguration.KEYSTORE_FILENAME).fake();
SystemOperationFaker.faker()
.forInstance(systemOperation)
.withPath(keyStoreDirectory, keyStoreDirPath).fake();
ConfigurationFaker.faker()
.forInstance(configuration)
.withKeyStoreLocation(keyStoreDirectory).fake();
UserInterfaceAdapterPortFaker.faker()
.forInstance(userInterfaceAdapterPort)
.withTheseSecureInputs(incorrectPassword, incorrectPassword, correctPassword).fake();
givenLoginFails(incorrectPassword, keyStoreFilePath);
givenLoginSucceeds(correctPassword, keyStoreFilePath);
// when
final var actual = cryptoProviderFactory.createCryptoProvider();
// then
assertThat(actual).isNotNull().isInstanceOf(Cipherizer.class);
}
Faker für Dateiobjekte
Auch Klassen aus dem JDK lassen sich hervorragend durch Faker für Testfälle simulieren. So verwenden die Tests in Passbird einen FileFaker
und einen PathFaker
. Der FileFaker
stellt Methoden bereit, um zu simulieren, ob es sich bei einem Dateiobjekt um ein Verzeichnis handelt, ob die Datei existiert oder einen bestimmten Namen hat:
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class FileFaker {
private File file;
public static FileFaker faker() {
return new FileFaker();
}
public FileFaker fakeFile() {
file = mock(File.class);
return this;
}
public FileFaker withDirectoryProperty(final boolean isDirectory) {
given(file.isDirectory()).willReturn(isDirectory);
return this;
}
public FileFaker withExistsProperty(final boolean exists) {
given(file.exists()).willReturn(exists);
return this;
}
public FileFaker withParentFile(final File parentFile) {
given(file.getParentFile()).willReturn(parentFile);
return this;
}
public FileFaker withName(final String name) {
given(file.getName()).willReturn(name);
return this;
}
public FileFaker withUri(final URI uri) {
given(file.toURI()).willReturn(uri);
return this;
}
public File fake() {
return file;
}
}
Der folgende Test prüft unter Verwendung des FileFakers ab, dass die Drittanbieterlizenzen-Datei ordnungsgemäß über den im System konfigurierten Standard-Webbrowser geöffnet wird.
@Test
void shouldOpenHtmlFile() throws IOException, URISyntaxException {
// given
final var uri = new URI("test");
final var givenFile = FileFaker.faker()
.fakeFile()
.withName("test.html")
.withUri(uri).fake();
final var desktop = mock(Desktop.class);
given(systemOperation.getDesktop()).willReturn(desktop);
given(systemOperation.openFile(givenFile)).willCallRealMethod();
// when
systemOperation.openFile(givenFile);
// then
then(desktop).should().browse(uri);
}