BDD UI Tests mit Serenity und JBehave

In meinem aktuellen Projekt schreiben wir die UI Tests als BDD Tests, da diese eher dazu führen, dass man fachliche Tests schreibt. Dazu verwenden wir Serenity zusammen mit JBehave.  Serenity verwendet dabei unter der Haube Selenium für die Browserautomatisierung. Dabei haben wir in meinem Team einen längeren Leidensweg hinter uns da zwischenzeitlich die Tests so schwer zu warten waren, dass wir mit dem Gedanken gespielt haben, die ganzen BDD /JBehave Tests wegzuschmeißen und durch reine Java Tests zu ersetzen. Letztendlich sind wir aber nach langer Zeit (dank Beratung eines anderen Teams) nun endlich bei einer Lösung angekommen, wo alles zusammenpasst und wir nun sehr zufrieden mit unseren UI Tests sind. Darum soll es in diesem Artikel gehen. 

Das Grauen am Anfang:

So sahen unsere JBehave Tests am Anfang aus:

Scenario: a self registered user is able to book
Given an self registered user was able to open the booking data page for an empty door to door booking with package lines with weight from ES to US
When the user provides valid es door to us door data
And the user proceeds to the booking payment page
And the user provides valid payment data
And the user proceeds to the booking review page
And all previously provided information is shown on the booking review page
And the user agrees to the terms and conditions
And the user places the booking
Then the control center is shown

Das Problem an obiger Testdefinition ist, dass schwer zu verstehen ist, was für diesen Test wirklich wichtig ist. Es gibt immerhin Tests, die die meisten Sätze teilen und somit fast identisch sind:

Scenario: a booking with packagelines and weight is completly filled in the workflow and submit booking is clicked
Given an full user was able to open the booking data page for an empty door to door booking with package lines with weight from ES to US
When the user provides valid es door to us door data
And the user proceeds to the booking payment page
And the user provides valid payment data
And the user proceeds to the booking review page
And all previously provided information is shown on the booking review page
And the user agrees to the terms and conditions
And the user places the booking
Then the control center is shown

Um herauszufinden was die beiden Tests unterscheidet muss man schon ziemlich suchen.

Weiter muss man für jeden dieser Sätze wie bspw. the user provides valid payment data JBehave mitteilen, was es tun soll wenn dieser Satz auftaucht. Dieses tut man über Java Methoden die mit speziellen JBehave Annotationen (@When, @Given, @Then usw.) versehen sind:

@When("the user proceeds to the booking review page")
@Alias("the user tries to proceed to the booking review page")
public void whenTheContinueButtonIsClicked() {
    bookingPaymentPage.clickContinue();
}

@When("the user provides valid payment data")
public void whenTheUserProvidesValidPaymentData() {
    final AddressWidgetObject invoiceAddress = bookingPaymentPage.invoiceAddress();
    invoiceAddress.companyName("Billing company name");
    invoiceAddress.street("Pickup street");
    invoiceAddress.city("Redwood City");
    invoiceAddress.fax("0123456789");
    invoiceAddress.phone("1234567");
    invoiceAddress.postalCode("08000");
}

@Given("an user was able to open the booking payment page for an bookable door to door booking")
@Composite(steps = {
    "Given an user was able to open the booking data page for an bookable door to door booking",
    "Given the booking payment page is requested on the UI" })
public void anUserWasAbleToOpenTheBookingPaymentPageForAnBookableDoorToDoorBooking() {
        // intentionally left blank due to @Composit
}

@Given("an user was able to open the booking data page for an bookable door to door booking")
@Composite(steps = {
    "Given the user opens the login page and provides authentication credentials and logs in",
    "Given a booking from door to door",
    "Given a quote from door to door",
    "Given the booking is placed after the quote request",
    "Given the booking data page is requested on the UI" })
public void anUserWasAbleToOpenTheBookingDataPageForAnBookableDoorToDoorBooking() {
        // intentionally left blank due to composite step
}

Da wir nun sehr viele Sätze brauchen, um sein ein Szenario wie bspw. a self registered user is able to book zu beschreiben, brauchen wir auch sehr viele verstreute Java Methoden (so genannte Step Definitionen) um zu definieren was getan werden muss. Das Problem dabei ist, dass diese Methoden teilweise aufeinander aufbauen. So funktioniert der Schritt the user provides valid payment data nur, wenn man auch auf der richtigen Seite ist. Das ist aber schwer zu handhaben, wenn diese Java Step Definitionen so verstreut sind. Auch muss eine Java Step Definition teilweise in der globale Variable Daten ablegen, damit ein spätere Java Step Definition darauf zugreifen kann,  bspw. um über Assertions Werte zu prüfen oder auch um  weitere Daten einzugeben.

Auch sieht man im obigen Code Beispiel, dass wir versucht haben mehrere Steps über die @Composit Annotation zusammenzufassen. Das verwirrt noch zusätzlich. Will man rausfinden was bei dem Step an user was able to open the booking data page for an bookable door to door booking passiert kann man, da es noch in der JBehave Datei steht, direkt auf den Satz klicken und springt zu der zugehörigen Java Definition:

@Given("an user was able to open the booking payment page for an bookable door to door booking")
@Composite(steps = {
    "Given an user was able to open the booking data page for an bookable door to door booking",
    "Given the booking payment page is requested on the UI" })
public void anUserWasAbleToOpenTheBookingPaymentPageForAnBookableDoorToDoorBooking() {
        // intentionally left blank due to @Composit
}

Dieses ist nun so ein @Composit Step, d.h. an sieht noch nicht was eigentlich passiert. Das aktuelle IntelliJBehave Plugin für  IntelliJ (Version 1.2) bietet keine Unterstützung davon, um nun zu den nächsten Step Definition zu springen. Das einzige was bleibt ist also Java Dateien nach  an user was able to open the booking data page for an bookable door to door booking oder the booking payment page is requested on the UI zu durchsuchen. Macht man das kommt man zur nächsten Java Methode:

@Given("an user was able to open the booking data page for an bookable door to door booking")
@Composite(steps = {
    "Given the user opens the login page and provides authentication credentials and logs in",
    "Given a booking from door to door",
    "Given a quote from door to door",
    "Given the booking is placed after the quote request",
    "Given the booking data page is requested on the UI" })
public void anUserWasAbleToOpenTheBookingDataPageForAnBookableDoorToDoorBooking() {
        // intentionally left blank due to composite step
}

Die wieder aus Composit Steps besteht nach denen man wieder sucht. Es ist also ziemlich mühsam, zu den Stellen zu navigieren, wo auch mal etwas passiert. Verbunden damit dass die Methoden nur funktionieren, wenn bestimmte Vorbedingungen erfüllt sind und sie sich globale Zustand teilen handelt es sich bei der jetzigen Lösung um Parade Beispiel für schwer wartbaren Spaghetticode.

Nach dem obigen Muster hatten wir mehrere Tests und wir merkten, dass sie immer unwartbarer wurden. Glücklicherweise hatte ein anderes Team sich schon sehr mit dem Schreiben von solchen Tests beschäftigt und half uns im Rahmen einer Mob Programming Session auszuarbeiten, wie wir von nun an unsere Tests schreiben wollen. Diese neue Lösung möchte ich im folgenden vorstellen.

Neue Lösung:

Die neuen JBehave Stories sehen wie folgt aus:

Scenario: a self registered user is able to book
Given any valid booking
And a self registered user
When the user completes the full booking workflow
Then the user sees the confirmation that it has been booked

Verglichen mit vorher ist es nun deutlich kürzer. Man sieht nun auch schnell, was der Unterschied zu anderen Tests ist:

Scenario: a booking with package lines and total weight can be booked
Given any valid booking with package lines and total weight
And a valid ui user
When the user completes the full booking workflow
Then the user sees the confirmation that it has been booked

Grundsätzlich darf im When und Then Teil nun nur noch ein Satz stehen. Im Given Teil sollen bevorzugt ein Satz bis maximal zwei Sätze stehen. Das führt zu kurzen und prägnanten Testbeschreibungen die klar machen sollen, was getestet wird. Im Given Teil darf nur ein Model gefüllt werden. Es dürfen keine Aktionen ausgeführt werden, bspw. mit dem Browser. Stattdessen werden alle Aktionen im When Teil ausgeführt.

Im Java Code sieht das dann so aus:

@Given("any valid booking")
public void aBookingWithCompleteData() {
    BookingModelSessionStorage.set(aValidBookingModel().build());
}

@Given("a self registered user")
public void aSelfRegisteredUser() {
    UserSessionStorage.set(usersConfiguration.getUserSelfRegistered());
}

@When("the user completes the full booking workflow")
public void theUserCompletesTheFullBookingWorkflow() {
    inMockBooking.opensAsAuthenticatedUser(UserSessionStorage.givenUser())
        .provideWeightEntryMode(BookingModelSessionStorage.givenBookingModel().getWeightEntryMode())
        .continueToBookingData()
        .providesBookingData(BookingModelSessionStorage.givenDataModel())
        .continuesToPaymentPage()
        .providesPaymentData(BookingModelSessionStorage.givenPaymentModel())
        .continuesToReviewPage()
        .acceptTermsAndConditions()
        .confirmBooking();
}

@Then("the user sees the confirmation that it has been booked")
public void theUserSeesTheConfirmationThatItHasBeenBooked() {
    inContolCenter.checkThatTheUserSeesTheBooking();
}

Die beiden mit @Given versehenen Methoden führen noch keine Aktionen gegen den Server aus sondern befüllen nur ein internes Model im so genannten Session Storage das für das Szenario gilt. Würde einer der Given Steps schon Aktionen gegenüber den Server ausführen wie bspw. eine Buchung anzulegen, sich einloggen o.ä. dann müsste man wissen, welche Steps Aktionen ausführen und welche nur ein internes Model befüllen. Auch sollte bspw. der Step any valid booking  noch gar keine Buchung anlegen, da in anderen Szenarien Given Steps verwendet werden, die die Daten noch verändern und bspw. Pflichtdaten aus der Buchung wieder löschen um zu testen, dass man mit diesen unvollständigen Daten nicht die Seite verlassen kann:

Scenario: Can not continue with incomplete payment data
Given any valid booking
And some required part of the payment address has not been filled
When the user continues from payment
Then the user is not able to proceed from payment

Der dazugehörige Java Code, der Pflichtdaten auf dem Model löscht, sieht wie folgt aus:

@Given("some required part of the payment address has not been filled")
public void someRequiredPartOfThePaymentAddressHasNotBeenFilled() {
   BookingModelSessionStorage.givenPaymentModel().setPaymentAddress(aValidPaymentAddress().companyName("").build());
}

Das ist alles kein Problem, da alle Aktionen mit dem Browser usw. nur im When Teil durchgeführt werden. Auch hat das Ausführen aller Aktionen im When Teil den Vorteil das man deutlich sieht, was passiert, da alles in einer Methode ausgeführt wird:

@When("the user completes the full booking workflow")
public void theUserCompletesTheFullBookingWorkflow() {
    inMockBooking.opensAsAuthenticatedUser(UserSessionStorage.givenUser())
        .provideWeightEntryMode(BookingModelSessionStorage.givenBookingModel().getWeightEntryMode())
        .continueToBookingData()
        .providesBookingData(BookingModelSessionStorage.givenDataModel())
        .continuesToPaymentPage()
        .providesPaymentData(BookingModelSessionStorage.givenPaymentModel())
        .continuesToReviewPage()
        .acceptTermsAndConditions()
        .confirmBooking();
}

Dieses hat auch den Vorteil, dass man die Tests besser debuggen kann sowie gute IDE Unterstützung für die Navigation hat.

Im obigen When sind alle Schritte für den Test wichtig da hier der komplette Flow durchlaufen werden soll. Testen wir bspw. eine bestimmte Seite, wie bspw. die Payment Seite, ist das navigieren zu der Payment Seite für den Test nicht so relevant – der eigentliche Test geht erst los wenn wir auf der Seite sind. Scheitert schon einer der vorherigen Steps beim navigieren auf die Seite, wollen wir schnell erkennen können, das der Fehler wahrscheinlich woanders liegt. Beispielsweise liegt wahrscheinlich kein Problem im Payment vor wenn der Login nicht geht. In solchen Tests unterscheiden wir die Vorbedingung vom eigentlichen Test dadurch, dass wie die Vorbedingung in eine Methode precondition auslagern:

@When("the user continues from payment")
public void theUserContinuesFromPayment() {
    precondition();
    inPaymentDetails.providesPaymentData(givenPaymentModel());
    inPaymentDetails.continues();
}

private void precondition() {
    inMockBooking.opensAsAuthenticatedUser() //
        .provideWeightEntryMode(givenBookingModel().getWeightEntryMode())
        .continueToBookingData()
        .providesBookingData(givenDataModel())
        .continuesToPaymentPage();
}

Die JBehave Step Definitionen alleine reichen nicht aus. Grundsätzlich verwenden wir drei Arten von Klassen:

Die JBehave Steps definieren den Java Code zu den Sätzen (wie Given any valid booking) in den JBehave Story Dateien. Sie verwenden die Serenity Page Steps (nicht zu verwechseln mit JBehave Steps) um die Seiten zu bedienen. Diese kapseln die fachlichen Dinge, die der Benutzer mit der Seite machen kann. So kann so eine Klasse bspw. aussehen:

public class BookingPaymentPageSteps extends ScenarioSteps {

    // wird automatisch von Serenity injected
    private BookingPaymentPage bookingPaymentPage;

    @Steps
    private BookingReviewPageSteps reviewSteps;

    @Steps
    private BookingDataPageSteps dataSteps;

    @Step
    public BookingPaymentPageSteps checkThatTheUserIsNotAbleToProceed() {
        bookingPaymentPage.shouldDisplayValidationErrors();
        return this;
    }

    @Step
    public BookingPaymentPageSteps continues() {
        bookingPaymentPage.clickContinue();
        return this;
    }

    @Step
    public BookingReviewPageSteps continuesToReviewPage() {
        continues();
        reviewSteps.checkThatTheUserSeesTheReviewPage();
        return reviewSteps;
    }

    @Step
    public BookingPaymentPageSteps checkThatTheUserSeesThePaymentPage() {
        assertThat(bookingPaymentPage.waitForPage()).isNotNull();
        return this;
    }

    @Step
    public BookingPaymentPageSteps providesPaymentData(final BookingPaymentModel bookingPaymentModel) {
        final AddressWidgetObject widget = bookingPaymentPage.invoiceAddress();
        widget.provideAddressWithoutPostalCode(bookingPaymentModel.getPaymentAddress());
        final BookingContactModel pickUpContact = bookingPaymentModel.getPaymentContact();
        widget.fax(pickUpContact.getFax());
        widget.phone(pickUpContact.getPhone());
        return this;
    }

    @Step
    public BookingReviewPageSteps checkThatTheUserIsAbleToProceed() {
        reviewSteps.checkThatTheUserSeesTheReviewPage();
        return reviewSteps;
    }

    @Step
    public BookingDataPageSteps backToBookingData() {
        final String urn = bookingPaymentPage.getUrn();
        return dataSteps.openForUrn(urn);
    }

    @Step
    public BookingPaymentPageSteps checkThatTheUserSees(final BookingPaymentModel bookingPaymentModel) {
        final AddressWidgetObject widget = bookingPaymentPage.invoiceAddress();
        widget.shouldHaveAddressWithoutPostalCode(bookingPaymentModel.getPaymentAddress());
        final BookingContactModel pickUpContact = bookingPaymentModel.getPaymentContact();
        assertThat(widget.fax()).isEqualTo(pickUpContact.getFax());
        assertThat(widget.phone()).isEqualTo(pickUpContact.getPhone());
        return this;
    }

   }

Die @Step Annotation bewirken, dass die Aufrufe dieser Methoden inkl. Screenshot in dem Serenity Report auftauchen. D.h. man sieht nicht nur die fachlichen Schritte wie Given any valid booking sondern auch die tatsächlichen Aktionen die der Benutzer auf der Seite ausgeführt hat (wie bspw. providesBookingData):

Die Serenity Page Steps verwenden wiederum die Serenity Page Objekte:

@DefaultUrl("http://localhost:8080/fa/site/bookings/{1}/payment")
public class BookingPaymentPage extends AbstractBookingUiPageObject<BookingPaymentPage> {

    @FindBy(id = "invoiceAddress")
    private AddressWidgetObject invoiceAddress;

    @FindBy(id = "submit")
    private WebElementFacade submitButton;

    public BookingPaymentPage() {
        super(BookingPaymentPage.class);
    }

    public AddressWidgetObject invoiceAddress() {
        return invoiceAddress;
    }

    public void clickContinue() {
        submitButton.click();
    }

    @Override
    protected BookingUrlParser urlParser() {
        return new BookingUrlParser("payment");
    }
}

Diese Aufteilung der Serenity Klassen in zwei Arten (Page Objekte und Page Steps) hat den Grund, dass die Serenity Page Objekte von Haus aus schon viele Low Level Methoden anbieten mit denen man bspw. Html Elemente suchen kann. Wir wollen aber nicht, dass man beim Schreiben von Tests auf diese Methoden außerhalb der Page Objekte zurückgreift und wir wollen dass man sofort sieht, welche Aktionen wie bspw. continueToReviewPage() auf der Seite möglich sind. Diese Aktionen die eine Seite anbietet, werden von der Serenity Page Step Klasse für eine bestimmte Seite angeboten. Dort gehen sie dann auch nicht in der Masse der Methoden unter, die Serenity von Haus aus für Page Objekte anbietet.

Fazit:

Wir haben nun schon viele Tests nach diesem neuen Prinzip geschrieben und alle alten Tests migriert. Die Tests sind nun deutlich klarer, mehr fachlich und besser zu warten. Auch die Benutzung von Serenity und JBehave fühlt sich nun gut an, vorher war es ziemlich mühsam.