Test-Säulen und Test-Treppe (Test-Pillars and Test-Stairs)

Wer sich mit Continuous Delivery und Testen auseinandersetzt kommt früher oder später an der Testpyramide vorbei. Die Mehrheit aller Tests sind Unit Test's, darauf aufbauend wird auf Stufe Komponenten oder Services getestet und ganz oben auf der Pyramide wird die Gesamtheit des Systems getestet. Nach oben wird die Anzahl der Tests kleiner. In diesem Artikel möchte ich einen Weg beschreiben, bei dem die Tests der oberen Stufen auch in den Tests der unteren Stufen ausgeführt werden und um weitere Tests auf jeder Stufe ergänzt werden. Das Ergebnis ist eher eine Stufenpyramide und das Vorgehen führt erstaunlich einfach an Domain Driven Design heran. Die Idee der aufbauenden Test habe ich von Jan Wloka der am SocratesDay CH 2015 eine vergleichbare Vorgehensweise beschrieben hat. (Vielen Dank nochmal dafür!).

Die Sicht des Benutzers

Nehmen wir folgende (sehr stark vereinfachte) Situation an:
Ein Versicherungsunternehmen möchte ein System bauen, welches Offerten und Verträge verwaltet. Es wird unter anderem folgender Anwendungsfall definiert: "Ein Kundenberater erstellt dem Kunden ein verbindliches Angebot basierend auf einem Vorschlag."
Nun wäre es doch schön, wenn man auf allen Teststufen diesen Test ausführen könnte. Dazu muss man aber sowas wie eine Beschreibung des Anwendungsfalles haben und dann je nach Granularität oder Technik die Implementierung auf der jeweiligen Stufe erstellen.

Die Formulierung des Anwendungsfalls im Source Code

Um das Beispiel einfach zu halten habe ich den Weg ohne viele Frameworks gewählt - Java und JUnit sollen für das Bespiel reichen:
public abstract class UC2Test {
 
    @Test
    public void createOffer() throws Exception {
        // given
        Proposal aProposal = aProposal();
        Actor aCustomerRepresentative = aCustomerRepresentative();
 
        OfferCreation offerCreation = anOfferCreation();
 
        // when
        Offer offer = offerCreation.createOffer(aCustomerRepresentative, aProposal);
 
        // then
        assertThat(offer, notNullValue());
    }
 
    protected abstract Actor aCustomerRepresentative();
 
    protected abstract OfferCreation anOfferCreation();
 
    protected abstract ValidUntilProvider aValidUntilProvider();
 
    protected abstract Proposal aProposal();
}
Allfällige Tests müssen nun die abstrakten Methoden implementieren. Um dies zu vereinfachen steht noch eine Implementierung zur Verfügung, die alle Methoden mit Dummies implementiert:
public abstract class UC2TestWithDummies extends UC2Test {
 
    @Override
    protected Actor aCustomerRepresentative() {
        return new Actor() {
        };
    }
 
    @Override
    protected OfferCreation anOfferCreation() {
        return (aCustomerRepresentative, proposal) -> new Offer();
    }
 
    @Override
    protected ValidUntilProvider aValidUntilProvider() {
        return LocalDate::now;
    }
 
    @Override
    protected Proposal aProposal() {
        return new Proposal() {
            @Override
            public boolean isComplete() {
                return true;
            }
 
            @Override
            public int getId() {
                return 1;
            }
        };
    }
}

Implementierung des System Integration Tests

Implementieren wir nun die Tests die Pyramide von oben nach unten und fangen ganz oben mit den System Integration Tests an:
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(Application.class)
@WebIntegrationTest
public class UC2SITest extends UC2TestWithDummies {
 
    @Before
    public void setUp() throws Exception {
    }
 
 
    @Override
    protected Actor aCustomerRepresentative() {
 
        setBaseUrl("http://localhost:8080");
        beginAt("/proposals");
 
 
        return new Actor() {
        };
    }
 
    @Override
    protected OfferCreation anOfferCreation() {
        clickLink("proposal-2");
 
        return (aCustomerRepresentative, proposal) -> {
 
            clickButton("create-offer");
            assertTitleEquals("Offer");
 
            return new Offer();
        };
    }
}
Wie leicht zu erkennen ist, wird hier die vollständige Spring Boot Applikation gestartet und der Ablauf auf der Webseite getestet.

Implementierung eines Komponenten-Tests

Gehen wir die Testpyramide weiter nach unten und sehen wir uns die Implementierung eines Komponenten Tests an. Immer noch wird der gleiche Testfall ausgeführt, nun einfach nicht mehr via der Web-Seite sondern auf Ebene der Komponenten:
public class OfferCreationWithProposalForIndividualTest extends UC2TestWithDummies {
 
    @Override
    protected Proposal aProposal() {
        return aCompleteProposalForIndividuals();
    }
 
    public static ProposalForIndividual aCompleteProposalForIndividuals() {
        ProposalForIndividual result = new ProposalForIndividual(1);
        result.setCustomerName("gonso");
        return result;
    }
}
Hier testen wir eine eine bestimmte Komponente, d.h. ein bestimmtes Zusammenspiel von Klassen - in dem Fall interessiert uns das Verhalten im Fall einer natürlichen Person.

Implementierung des Unit-Tests

Zum Schluss unseres kleinen Beispiels implementieren wir nun noch den Unit Test für die Default-Implementierung:
public class OfferCreationUnitTest extends UC2TestWithDummies {
 
    @Override
    protected OfferCreation anOfferCreation() {
        return new OfferCreationDefault(aValidUntilProvider());
    }
 
    ...
}
Wenn man nun einen Anwendungsfall nach dem anderen auf diese Weise testet (UC-1, UC-2, ...), so reihen sich die Tests wie Säulen über alle Stufen aneinander:
saeulen

Testsäulen

Es werden auch Test entstehen die keine vollständige Entsprechung in Anwendungsfällen haben. Solche Tests können z.B. bestimmte Aspekte isoliert testen. Idealerweise sind dies Details die im Laufe der Implementierung mit den Anwender abgesprochen werden. Wenn wir also dann mit solchen Tests eine Stufe tiefer beginnen und sie auf die gleiche Weise implementieren - d.h. in allen Stufen darunter - dann haben wir eine Stufe. Ausserdem werden wir auch Unit-Tests haben, die keine Tests über sich haben, um z.B. technische Randbedingungen (Null-Test, MAX-Werte Tests, etc) zu prüfen:
stufen.draw.io

Testtreppe

BDD und Cucumber

Ich habe es auch mit Cucumber und "richtigem" Behavior Driven Development (kurz BDD) versucht. Leider bin ich gescheitert, da eine abstrakte Spezifikation mit Cucumber nicht möglich scheint - bzw ich nicht herausbekommen habe, wie es funktionieren könnte. 🙁

Eine Bewertung

Interessanter Weise treibt einen dieses Vorgehen in die Arme des Domain Driven Design. Der Test wird so nah an der Anforderung beschrieben, dass die dann entstehenden Schnittstellen und Implementierungen die Sprache der Anwender abbilden. Dies trifft auch auf die Methodennamen zu. Was damit einhergeht ist, dass sehr einfach feststellbar ist, welche Konsequenzen eine fachliche Änderung zur Folge hat. Man muss nicht mehr auf technischer Ebene die Aufwände argumentieren und kann sehr einfach herausfinden, welche Teile der Applikation von der fachlichen Änderung betroffen sind. Was ich noch nicht probiert habe, ob sich dieses Vorgehen auch bei anderen Testarten, wie z.B. Performancetests, nach "unten" durchhalten lässt. Die strenge top-down Vorgehensweise hilft auch sich auf die tatsächlich zu implementierenden Funktionen zu konzentrieren und sich nicht zu "verzetteln".

Weiterführendes Material

Source Code

 

Leave a Reply

Your email address will not be published. Required fields are marked *