/ refactoring

Konsolidierung von Businesslogik mit Hilfe von ATDD und Feature Toggle

Wie viele andere Unternehmen, stehen auch wir immer wieder veraltetem Code gegenüber. Die Marktplatz-Unit der EUROPACE AG verantwortet u.a. die Webanwendung zur Beratung und Vermittlung einer Baufinanzierung, BaufiSmart.

Viele Prozesse sind in diesem Produkt enthalten, so wie die Abbildung der Provisionen des Vermittlers am jeweiligen Ergebnis, um im Falle eines Antrags daraus die entsprechenden Zahlungsforderungen im Backendsystem zu erzeugen.

Anwendungsfall

Wie bei allen lebenden Softwareprodukten, verändern sich Anforderungen oder Sachverhalte. Das macht die Evolution der Software aus. Nicht selten entstehen bei solchen Weiterentwicklungen Inkonsistenzen oder Implementierungen, die rückblickend auf andere Art sauberer umzusetzen gewesen wären, auch unter dem Begriff Technische Schulden bekannt.

Im Beispielfall haben wir uns in einer Story der Aufgabe gewidmet, die Berechnung und Darstellung der Provisionen wieder zu zentralisieren. Eine Geschäftsregel sollte DRY in der Software abgebildet sein. Weil dies nicht der Fall war, gab es Darstellungsfehler in bestimmten Featurekonstellationen. Die Logik zur Verteilung von Provisionen umfasste ca. 20 Klassen. Die meisten von ihnen waren im gleichen Package zu finden.
[comment]:<>(DRA: @ACM, meinst Du dass es hier ausreicht, um einen Anhaltpunkt zur Komplexität zu liefern?)

ATDD als Kompass

Acceptance Test Driven Developement ermöglicht es, die Erwartungen an den Code auf einer höheren Ebene zu formulieren, als dies in Unittests und dem TDD möglich ist. Notwendig wird das, wenn man SRP (Single Responsibilty Principle) und OCP (Open Close Principle) folgt. Beides wird dazu führen, dass man eine Menge an spezialisierten, kleinen Klassen hat, die im Gesamten ein Ergebnis erzeugen.

Bestandsaufnahme

Die Acceptance Tests helfen somit, das aktuelle Verhalten des Codes zu beweisen. Einerseits wird die Testabdeckung erhöht, auf der anderen Seite muss sich der neue Code gegen diese Tests beweisen.

[comment]:<>(DRa: @acm @IDF meint Ihr ich kann das echte Testbeispiel hier so veröffentlichen? Ich halte es unbedenklich)
[comment]:<>(IDF: @dra würde ich ähnlich sehen. Allerdings unter Vorbehalt, da nur eingeschränkte Erfahrung bei EP.)
[comment]:<>(ACM: @dra ich denke auch, dass es OK sein sollte. Steckt ja kein besonderes Geschäftsgeheimnis dahinter (oder etwa doch?). Im Nachhinhein fällt mir nur auf, dass "initProvisionsVerteilung" doch etwas "unnatürlich" klingt... "mitProvisionsVerteilung" wäre noch eine Möglichkeit.)

Da das Setup (Fixture) eines Tests technisch sehr aufwändig sein kann, haben wir diese Details hinter einer DSL verborgen. Somit liest sich das Setup fast wie ein natürlichsprachlicher Satz.

Beispiel für eine Geschäftsregel: Die Provision des Tippgebers wird aus der Provision des Kundenbetreuers entnommen.

  @Test
  public void der_Tippgeber_partizipiert_an_der_KuBe_Provision__nur_Darlehen() {
    // given
    final Angebot angebot = new Angebot();
    f.initialisiere(angebot).mit(
        f.annu().darlehensBetrag(100_000).gesamtProvision(1_000)
            .mitProvisionsVerteilung(f.empfaenger(KUNDEN_BETREUER_ID).provision(1_000)))
                            .mit(f.tippgeber().betrag(400));

[comment]:<>(SSH: @DRA Ich frage mich bei den Beispielen, was f ist. Ah da unten steht es, am besten den letzten Absatz nach oben vor die Tests ziehen. Ist es okay, die Berechnung für den Tippgeber öffentlich zu machen?)
[comment]:(ACM: @dra können wir davon ausgehen, dass der Leser sich die konkrete Businessregel selbst aus dem Testcode erschliessen kann, oder wäre es besser, die Provisionsverteilungsregel auch noch mal "in natürlicher Sprache" zu erklären?)


Und da die gleiche Modellkomplexität auch die Assertions schnell unübersichtlich werden lassen, gibt es dafür eine analoge DSL.

    // when
    anreicherer.reichereAngebotMitProvisionenAn(angebot, KUNDEN_BETREUER_ID, Optional.of(TIPPGEBER_ID));

    // then
    f.pruefe(angebot).hatKuBeProvisionRelativ(0.6)
        .hat(darlehen(0).hatKuBeProvisionRelativ(0.6).hatKuBeProvisionAbsolut(600)
                 .mit(provisionsVersprechen(KUNDEN_BETREUER_ID, 1000, KUBE, PROVISIONS_STRATEGIE),
                      provisionsVersprechen(KUNDEN_BETREUER_ID, -400, KUBE, PARTIZIPATION_AN_PROVISION),
                      provisionsVersprechen(TIPPGEBER_ID, 400, TIPPGEBER, PARTIZIPATION_AN_PROVISION))
                 .mitProvisionsSumme(1_000));
  }


Das Fixture f wird als State für jeden Test initialisiert, und stellt das notwendige Tooling bereit. Alle involvierten Klassen werden für diesen Test instanziiert. Das ist hier konkret mit Hilfe eines spezifischen Spring-Kontextes geschehen.

Refactoring

Wir konnten mit Hilfe dieser Acceptance Tests schnell und zuverlässig neuen Code entwickeln und diesen mit bestehendem Code kombinieren. Am Ende hatten wir die Gewissheit, dass das alte Verhalten beibehalten, und vorhandene Fehler beseitigt wurden. Über neue Tests kamen dann neue Funktionen hinzu, die bislang verteilt an anderen Stellen im Produkt abgebildet waren.

Qualitätssicherung

Unittests haben wir zur Ausgestaltung der jeweiligen Klassen entwickelt, nachdem bewiesen war, dass das Klassenmodell die Anforderungen erfüllt.
Auf diese Weise kann man explorative und testgetriebene Entwicklung kombinieren und vermeidet Schmerzen, da Unittests seltener anzupassen sind. Die Softwarearchitektur stabilisiert sich schneller, da seltener Anwendungsfälle übersehen werden.

Continuous Delivery [1]

Wir folgen dem agilen Prinzip "fail fast", daher integrieren wir sofort alle im zentralen Repository eingecheckten Änderungen. Nachdem die Änderungen die Testpipeline erfolgreich durchlaufen haben, deployen wir automatisch auf dem Produktivsystem. Das hat zur Folge, dass wir auch eine Strategie benötigen, um alte und neue Funktionalität nebeneinander auf dem Produktivsystem koexistieren lassen zu können.

Feature Toggle

Um neue Features während der Entwicklung für Endanwender unsichtbar zu halten, verwenden wir Feature Toggle, die sich mit Cookies aktivieren lassen. Zusätzlich bedarf es eines Rechtes, um überhaupt neue Features anschauen zu können. Ein Feature Toggle funktioniert sowohl im Frontend, als auch im Backend, wenn eine entsprechende Weiche vorgesehen wurde.

public class Presenter {
  private Feature feature;
  
  @Inject
  public Presenter(Feature feature) {
    this.feature = feature;
  }

  public void init() {
     if (this.feature.isActive("FeatureName")) {
       // activate new logic
     } else {
       // activate old logic
     }
  }

  ...

}


Im vorgestellten Anwendungsfall waren viele Stellen betroffen, und erforderten daher auch mehr als 20 Weichen, die durch einen Cookie gesteuert wurden.

Aktivierung

Anstatt hier mit einer Konstanten zu arbeiten, haben wir eine entsprechende Bean implementiert, die die Entscheidung zentral vornimmt. Vorteil dieser Vorgehensweise ist, dass die Aktivierung der neuen Funktionalität nur einen Commit mit einer Änderung an dieser Bean erfordert.

public class ConcreteFeature {
  private Feature feature;
  
  @Inject
  public Presenter(Feature feature) {
    this.feature = feature;
  }
  
  public boolean isActive() {
    // become DRY in feature toggling
    return this.feature.isActive("FeatureName");
  }

}

public class Presenter {
  private ConcreteFeature feature;
  ...
  
  public void init() {
    if (feature.isActive()) {
      ...
    }
  }
  ...

}

# Fazit Diese zwei Methoden (ATDD und Feature Toggle) reduzieren die fachliche Komplexität in einer Story. Sie erhöhen die Codequalität nachhaltig und hinterlassen in Form der Acceptance Tests fachlich gut verständliche Dokumentation im Quellcode.

[comment]:<>(SSH: @DRA Ich würde hier noch ergänzen, dass es besonders bei Legacy Code Vorteile bietet und das Refactoring wesentlicht vereinfacht. Das habe ich zumindest noch mitgenommen. Ansonsten klar strukturiert und gut verständlich geschrieben, hat mir gefallen.)


  1. Continuous Delivery ist keine Technologie ↩︎