Personas für Unit Tests (Teil 2)

In jedem Projekt entwickelt sich nach einiger Zeit ein eigenes Ökosystem von Klassen, die sich um die Bereitstellung von Testdaten für Unit Tests und Integrationstests kümmern. Manchmal bleibt dieses Ökosystem klein und übersichtlich, häufig jedoch wuchert es zu einem undurchschaubaren Dschungel an.

JUnit 5 bietet mit seiner Extension Model einen leichtgewichtigen Ansatz um Testdaten direkt in die jeweilige Testmethode zu injizieren. Im Beitrag Personas für Unit Tests wurde gezeigt, wie mit dem ParameterResolver und dem InvocationInterceptor Testdaten vor der Testausführung generiert und manipuliert werden können. Ausgeblendet wurde dabei die Zuweisung der Persona Eigenschaften auf die Testobjekte im FakePersonaBuilder.

Mit Personas und ihren Eigenschaften sind in diesem Beitrag standardisierte Personendaten gemeint. Jede Persona besitzt einen festen Satz von Eigenschaften. Dies sind Geschlecht, Anrede, Titel, Vorname, Nachname, Telefonnummer, Login, Email-Adresse, Straße, Hausnummer, PLZ und Wohnort.

Bei der Zuordnung von Persona Eigenschaften zu den Attributen einer Java Klasse gibt es das Problem der Benamung. In unterschiedlichen Klassen, Bibliotheken, Projekten oder Firmen werden diese Attribute unterschiedlich benannt. Beispielsweise kann der Nachname in einer Bean als surname, surName, lastname, lastName, lname, sname oder nachname deklariert werden. Damit der FakePersonaBuilder diese Attribute zuordnen kann, werden die üblichen Attributnamen in einer Alias-Liste hinterlegt.

  private static final Map<String, PersonaAttribute> ALIASES = Map.ofEntries(
      Map.entry("gender", GENDER), Map.entry("sex", GENDER),
      Map.entry("title", TITLE),
      Map.entry("salutation", SALUTATION),
      Map.entry("firstname", FIRSTNAME), Map.entry("givenname", FIRSTNAME),
      Map.entry("lastname", LASTNAME), Map.entry("surname", LASTNAME),
      Map.entry("email", EMAIL), Map.entry("mail", EMAIL),
      Map.entry("telephone", PHONE), Map.entry("phone", PHONE),
      Map.entry("street", STREET),
      Map.entry("housenumber", HOUSENUMBER), Map.entry("number", HOUSENUMBER),
      Map.entry("zip", ZIP), Map.entry("city", CITY)
  );

Der Schlüssel in der Map ist der Attributname in der Bean und der Wert ist eine Enum Konstante für die jeweilige Persona Eigenschaft. Ein Attributname wird unterstützt, wenn ein entsprechender Eintrag case insensitive in der Map zu finden ist. Für manche Fälle ist diese Map ungeeignet, daher ist sie nur die Basis der tatsächlich verwendeten Map. Die tatsächlich verwendete Map kann über entsprechende Methoden um weitere Einträge ergänzt oder von bestehenden Einträgen befreit werden.

Die Personas sind in einer JSON Struktur hinterlegt. Dabei beinhalten die Eigenschaften keine Einzelwerte sondern Listen von Werten. Enthält die Liste mehrere Werte, dann wird der tatsächlich verwendete Wert gewürfelt. Auf diese Weise können die Testdaten für eine Persona im Tests variiert werden.

{
  "mitarbeiterin": {
    "gender": [ "female" ],
    "salutation": [ "Frau" ],
    "title": [],
    "firstname": [ "Andrea", "Bettina", "Claudia" ],
    "lastname": [ "Mustermann" ],
    "email": [ "mustermann@fakenews.de" ],
    "street": [ "Badstraße", "Turmstraße", "Südbahnhof", "Chauseestraße", "Elisenstraße", "Poststraße" ],
    "housenumber": [ "1", "2a", "3b", "4", "5c", "6", "7d", "8", "9", "10" ],
    "zip": [ "33334", "33619", "32257" ],
    "city": [ "Gütersloh", "Bielefeld", "Bünde" ]
  }
  "adam": {
    "gender": [ "male" ],
    "salutation": [ "Herr" ],
    "title": [ "Professor" ],
    "firstname": [ "Johann Adam" ],
    "lastname": [ "Weißhaupt" ],
    "street": [], "housenumber": [], "zip": [],
    "city": [ "Ingolstadt" ]
  }
}

Die Persona mitarbeiterin aus der JSON Struktur besitzt immer die Anrede Frau, da nur ein einzelner Wert hinterlegt ist. Als Straße besitzt die Persona immer eine Straße von der ersten Seite eines Monopoly Spiels.

Der FakePersonaBuilder verwendet eine Fluent API, damit ist der Aufruf und die Konfiguration in einem Statement möglich.

new FakePersonaBuilder().build("mitarbeiterin", person);

In diesem Beispiel wird der FakePersonaBuilder ohne weitere Konfiguration aufgerufen um die Attribute von person mit den Eigenschaften der Persona mitarbeiterin zu belegen. Komplexere Ausdrücke, mit verschiedenen Konfigurationsmöglichkeiten, sind aber auch möglich.

new FakePersonaBuilder()
  .ignore(PersonaAttribute.EMAIL)
  .override(PersonaAttribute.TITLE)
  .enable(Feature.NULL_ON_MISSING_ATTRIBUTE)
  .alias("nachname", PersonaAttribute.LASTNAME)
  .build("mitarbeiterin", person);

Der Großteil der Implementierung befindet sich in der build Methode. Um die Daten aus der Bean zu erhalten und die veränderten Daten wieder in die Bean einzufügen, bedienen wir uns eines alten Bekanten au den Beiträgen Dr. REST oder: Wie ich lernte Jackson zu lieben und Trivial Pursuit – API MarkDown.

  public <T> T build(String personaName, T bean) {
    requireNonNull(personaName);
    requireNonNull(bean);
    try {
      Map<String, Object> map = objectMapper.readValue(objectMapper.writeValueAsString(bean), new TypeReference<>() {
      });
      Map<String, Object> filled = fillPersona(personaName, map);
      objectMapper.readerForUpdating(bean).readValue(objectMapper.writeValueAsString(filled));
    } catch (JsonProcessingException e) {
      throw new ConversionException("cannot fill with personaName " + personaName, e);
    }
    return bean;
  }

Am Anfang der Methode wird der ObjectMapper aus der Jackson Bibliothek verwendet um die Werte aus der Bean in eine Map zu kopieren. Dann werden die Persona Eigenschaften in die Map kopiert. Als letztes wird die Map mit Hilfe der readerForUpdating Methode in die Bean zurückgeschrieben.

Für den Unit Tests illuminatus aus dem vorherigen Beitrag mit der Persona adam geschieht das folgende hinter den Kulissen.

@ExtendWith(PersonParameterResolver.class)
@ExtendWith(IdInvocationInterceptor.class)
@ExtendWith(FakeInvocationInterceptor.class)
class PersonaTest {
  @Test
  void illuminatus(@Id(23) @Fake("adam") PersonEntity person) {
    ...
  }

Zuerst wird die als Parameter übergebene PersonEntity in eine Map umgewandelt. Zuvor hat der PersonParameterResolver eine leere Instanz erzeugt und der IdInvocationInterceptor hat das id Feld mit dem Wert 23 belegt.

{
  "id":23,
  "gender":null,
  "firstname":null,"lastname":null,
  "street":null,"housenumber":null,
  "zip":0,"city":null,
}

In der JSON Darstellung sind alle Felder bis auf id mit null belegt. Befüllt mit den Werten für die Persona adam, ändern sich die Einträge gender, firstname, lastname und city.

{
  "id":1,
  "gender":"male",
  "firstname":"Johann Adam", "lastname":"Weißhaupt",
  "street":null,"housenumber":null,
  "zip":0, "city": "Ingolstadt" 
}

Nachdem die Map zurück in die PersonEntity geschrieben ist, enthält diese alle notwendigen Personendaten für den illuminatus Test.

Der Sourcecode für diesen Beitrag ist im Gitlab Projekt Fake Personas zu finden. Viel Spaß beim Testen mit Personas.