Blog: Java, Software-Entwicklung & mehr

Wicket-Anwendungen effektiv mit Selenide testen

21.07.2025 Michael Mosmann

Wenn man Anwendungen mit Wicket entwickelt, kann man Wicket-Komponenten bereits gut mit Bordmitteln testen (Tests mit WicketTester). Bei diesen Tests kann aber nicht das Verhalten im Browser getestet werden, die Tests finden mehr oder weniger nur auf Request-Ebene statt. Dafür sind diese Tests schnell und eignen sich gut, um grundsätzliches Komponentenverhalten zu testen.

Oberflächen-Tests sind ein wichtiger Baustein für robuste Webanwendungen. Sie stellen sicher, dass die Benutzeroberfläche auch im Zusammenspiel funktioniert und die Funktionen, die nur im Client ablaufen, ebenfalls getestet werden können.

Lange Zeit war Selenium dafür der Standard, wurde aber durch ein viel einfacher nutzbares Selenide abgelöst.

Im Folgenden soll veranschaulicht werden, wie man im Zusammenspiel von Wicket mit Selenide geeignete Abstraktionen finden kann, die bessere Tests und einfachere Refactoring-Möglichkeiten eröffnen.

Bausteine für Wicket-Tests

Die Basis bildet ein Projekt, das man sich schnell mit Apache Wicket Quickstart erzeugen kann und um Selenide ergänzt.

Beispielanwendung

Die Anwendung besteht aus zwei Seiten, wobei auf der ersten Seite ein Formular mit zwei Feldern und einem Pflichtfeld vorhanden ist. Des Weiteren gibt es Links zum Wechseln auf eine zweite Seite und dort einen Link um wieder auf die erste Seite zu gelangen. Es gibt eine selbst geschrieben Komponente (in der sich das Formular befindet).


public class FirstPage extends WebPage {
    private static final long serialVersionUID = 1L;

    public FirstPage(final PageParameters parameters) {
        super(parameters);
  
        add(new FormPanel("formPanel"));
  
        add(new Link<Void>("linkToSecondPage") {
            @Override
            public void onClick() {
              setResponsePage(SecondPage.class);
            }
        });
    }
}


public class SecondPage extends WebPage {
    private static final long serialVersionUID = 1L;

    public SecondPage(final PageParameters parameters) {
        super(parameters);

        add(new Link<Void>("linkBackToFormPage") {
            @Override
            public void onClick() {
                setResponsePage(FirstPage.class);
            }
        });
    }
}

Basistest mit Selenide

Die Integration von Selenide ist wirklich einfach. Anwendung starten, mit Selenide die Seite der laufenden Anwendung öffnen und fernsteuern. Ein einfacher Test für die Anwendung könnte so aussehen:


open("http://localhost:8080");

$("input[wicket\:id='name']").setValue("John Doe");
$("input[wicket\:id='email']").setValue("john@example.com");
$("input[type='submit']").click();

$("div[wicket\:id='feedback']").shouldHave(text("Hello John Doe!"));

Das sieht einfach aus und wäre für diese Anwendung auch vollkommen ausreichend. Schwieriger wird es, wenn die Komplexität der Anwendung wächst und die Selektoren nicht mehr so einfach zu beschreiben sind. Wer seine Anwendungen mit Wicket entwickelt, kann hier von dem Komponentenansatz auf unerwartete Weise profitieren.

Wicket-Anpassungen für Selenide

Wicket kann den vollständigen Pfad einer Komponente in ein Attribut in ein HTML-Tag rendern. Damit ist eine eindeutige Adressierung jeder Komponente, auch wenn sie auf der Seite mehrfach vorhanden ist, möglich. Außerdem kann man sich eine kleine Erweiterung bauen, die dafür sorgt, dass die aktuelle Seite als Kommentar im HTML auftaucht. Auf diese Weise kann man prüfen, auf welcher Seite man sich befindet. Das ist dann hilfreich, wenn Basiskomponenten auf allen Seiten eingebunden sind und es dann leicht ist, unter falschen Annahmen durch die Anwendung zu navigieren. Außerdem kann man bei Komponenten mit eigenem Markup Kommentare rendern lassen, die den Start und das Ende der Komponente im Quelltext markieren. Dazu muss man nur in der eigenen WicketApplication in der init-methode folgende Zeilen ergänzen:


if (RuntimeConfigurationType.DEVELOPMENT.equals(getConfigurationType())) {
    getDebugSettings().setOutputMarkupContainerClassNameStrategy(
        DebugSettings.ClassOutputStrategy.HTML_COMMENT);
    getDebugSettings().setComponentPathAttributeName("data-wicket-path");

    getHeaderResponseDecorators().add(response -> {
        response.render(new PageClassAsHeaderComment());
        return response;
    });
}

Der Code für die Klasse PageClassAsHeaderComment sieht wie folgt aus (die Methode PageClassAsHeaderComment.pageClassFromSourceCode() benötigen wir später in unseren Tests.):


public class PageClassAsHeaderComment extends HeaderItem {

    @Override
    public Iterable<?> getRenderTokens() {
        return Collections.emptyList();
    }

    @Override
    public void render(Response response) {
        // Get current page class from RequestCycle
        IRequestablePage component = RequestCycle.get().find(IRequestHandler.class)
            .map(handler -> {
                if (handler instanceof RenderPageRequestHandler) {
                    return ((RenderPageRequestHandler) handler).getPage();
                }
                return null;
                })
                .orElse(null);

        if (component instanceof Page) {
            Class<? extends Page> pageClass = ((Page) component).getClass();
            response.write("<!-- WicketPage("+pageClass.getName()+") -->");
        }
    }

    private static Pattern PATTERN=Pattern.compile("<!-- WicketPage\\((?<pageClassName>.+)\\) -->");

    public static Optional<String> pageClassFromSourceCode(String pageSource) {
        Matcher matcher = PATTERN.matcher(pageSource);
        if (matcher.find()) {
            return Optional.of(matcher.group("pageClassName"));
        }
        return Optional.empty();
    }
}

Und so sieht dann das HTML der laufenden Anwendung aus. Im Kopf der Seite finden wir den Klassennamen der Seite:


<head><!-- WicketPage(de.conceptpeople.ui.FirstPage) -->
    <meta charset="utf-8" />
    <title>Form Page</title>

Für jede Komponente findet sich im data-wicket-path-Attribut der Komponentenpfad:


<input type="text" wicket:id="name" id="name" value="" name="p::name" data-wicket-path="formPanel_form_name"/>

Für eine Komponente mit eigenem Markup findet sich ein (oder mehrere) Kommentar(e) auf der Seite:


<!-- MARKUP FOR de.conceptpeople.ui.FormPanel BEGIN -->

Auch wenn man eine Komponente durch die Angaben im data-wicket-path-Attribut eindeutig adressieren kann, kann man anhand des Kommentars überprüfen, ob es sich auch um die erwartete Komponente handelt, sofern sie ein eigenes Markup besitzt.

Wicket-Adapter

Wenn wir nun Adaptercode für den Test einer Wicket-Anwendung bauen, folgen wir grob dem Page Object-Pattern, sollten aber darüber hinaus auch die Komponentenstruktur nachempfinden.

Dabei benötigen wir allerdings nur Zugriff von "außen", also auf das durch Nutzer auslösbare Verhalten. Dazu müssen wir Eingabefelder befüllen, Links und Buttons klicken, Inhalte überprüfen. Für wiederkehrendes Verhalten kann man sich Funktionen anlegen, die mehrere Interaktionen bündelt und ausführt.

Wir starten mit einer Abstraktion, die eine Seite im Browser abbildet. In unserem Beispiel öffnet die Anwendung keine weiteren Fenster, sodass wir uns diesen Teil sparen können. Wenn es notwendig sein sollte, würde man an dieser Stelle z.B. die Nummer des Fensters (oder Tab) im Selenide-Kontext speichern und für Interaktionen, die dann im selben Fenster stattfindet mitgeben.


public class CurrentPage {</p>

    public CurrentPage newInstance() {
        return new CurrentPage();
    }

    public <T> T expect(Function<CurrentPage, T> checkingFactory) {
        return checkingFactory.apply(this);
    }

    public CurrentPage isPageClass(Class<? extends Page> pageClass) {
        String headHtml = Selenide.$(HasXPath.asXPath("//head").asSelector()).innerHtml();

        Optional<String> pageClassFromSource = PageClassAsHeaderComment.pageClassFromSourceCode(headHtml);

        assertThat(pageClassFromSource)
            .describedAs("page class comment: %s", headHtml)
            .isPresent();

        assertThat(pageClassFromSource.get())
            .describedAs("page class")
            .isEqualTo(pageClass.getName());

        return this;
    }

    public static CurrentPage open(String url) {
        Selenide.open(url);
        return new CurrentPage();
    }
}

In der Abstraktion für eine Wicket-Seite findet sich bereits die erste Prüfung, ob man sich auf der richtigen Seite befindet. Außerdem erstellen wir eine Factory-Methode, mit der dann z.B. Komponenten-Adapter erstellt werden können.


public abstract class WicketPage {
    private final CurrentPage page;</p>

    public WicketPage(CurrentPage page, Class<? extends Page> pageClass) {
        this.page = page;
        page.isPageClass(pageClass);
    }

    public <C> C component(String id, BiFunction<CurrentPage, WicketPath, C> componentFactory) {
        return componentFactory.apply(page, WicketPath.startWith(id));
    }
}

Da nicht alle Wicket-Komponenten im Markup sichtbar sind (z.B. weil man die Komponente mit <wicket:container> eingebunden hat), benötigen wir eine Basisklasse, die nur die notwendigsten Funktionen abbildet:


public abstract class WicketContainer<T extends WicketContainer<T>> {
    private final CurrentPage page;
    private final WicketPath path;</p>

    public WicketContainer(CurrentPage page, WicketPath path) {
        this.page = page;
        this.path = path;
    }

    protected CurrentPage page() {
        return page;
    }

    public <C> C component(String id, BiFunction<CurrentPage, WicketPath, C> componentFactory) {
        return componentFactory.apply(page, path.append(id));
    }

    public <E> E element(String xpath, TriFunction<CurrentPage, HasXPath, String, E> componentFactory) {
        return componentFactory.apply(page, path, xpath);
    }
}

Für alle anderen Fälle können wir verschiedene Prüfungen zu einem frühestmöglichen Zeitpunkt durchführen. Bei einer Standard-Wicket-Komponente finden wir das Element anhand des Pfades. Wir können sicherstellen, dass es vorhanden ist und wir können in dem HTML-Fragment den Kommentar passend zur Komponente finden. Dazu muss die Komponente aber eigenes Markup besitzen. In diesen Komponenten kann dann die Funktion WicketComponent.expectComponent() aufgerufen werden.


public abstract class WicketComponent<T extends WicketComponent<T>> extends WicketContainer<T> {
    private final SelenideElement element;
    private final Map<String, Long> componentCountMap;
    private final String innerHtml;</p>

    public WicketComponent(CurrentPage page, WicketPath path) {
        super(page, path);
        this.element = Selenide.$(path.asSelector());
        this.element.should(Condition.exist);
        this.innerHtml = this.element.innerHtml();
        this.componentCountMap = Components.componentCountMap(innerHtml);
    }

    protected SelenideElement element() {
        return element;
    }

    protected void expectComponent(Class<?> componentClass) {
        String name = componentClass.getName();
        Long count = componentCountMap.get(name);

        Preconditions.checkNotNull(count, "%s - component class not found: %s\n---\n%s\n",
            name, componentCountMap, innerHtml);
        Preconditions.checkArgument(count == 1, "%s - more than one component found: %s\n---\n%s\n"
        , name, count, innerHtml);
    }

    public final T hasText(String text) {
        element().shouldHave(Condition.exactText(text));
        return (T) this;
    }

    public final T attributeContains(String key, String... values) {
        for (String value : values) {
            element().shouldHave(Condition.attributeMatching(key, ".*" + value + ".*"));
        }
        return (T) this;
    }
}

Mit dieser Vorarbeit können wir für die erste Seite einen entsprechenden Adapter bauen:


public class FirstPage extends WicketPage {</p>

    private final FormPanelAdapter formPanel;
    private final Link linkToSecondPage;

    public FirstPage(CurrentPage page) {
        super(page, de.conceptpeople.ui.FirstPage.class);

        this.formPanel = component("formPanel",FormPanelAdapter::new);
        this.linkToSecondPage = component("linkToSecondPage",Link::new);
    }

    public FormPanelAdapter formPanel() {
        return formPanel;
    }

    public ActionResult clickLinkToSecondPage() {
        return linkToSecondPage.click();
    }
}

und diesen gleich im ersten Test benutzen.


CurrentPage.open("http://localhost:8080")
        .expect(FirstPage::new);

Auch wenn der erste Test sehr kurz aussieht, werden hier schon diverse Prüfungen vorgenommen:

  • Es wird geprüft, ob wir uns wirklich auf dieser Seite befinden
  • Es wird sichergestellt, dass die Seite ein FormPanel und ein Link enthält

Wenn wir uns den Adapter für das FormPanel anschauen, erkennen wir ein wiederkehrendes Muster:


public class FormPanelAdapter extends WicketComponent<FormPanelAdapter> {
    private final FeedbackPanelAdapter feedbackPanel;</p>

    private final Form form;
    private final TextField nameField;
    private final TextField emailField;
    private final HtmlTag.SubmitButton submitButton;

    public FormPanelAdapter(CurrentPage page, WicketPath path) {
        super(page, path);

        expectComponent(FormPanel.class);

        feedbackPanel = component("feedback", FeedbackPanelAdapter::new);
        form = component("form", Form::new);
        nameField = form.textField("name");
        emailField = form.textField("email");

        submitButton = form.element("/div/input[@type='submit']", HtmlTag.SubmitButton::new);
    }

    public FeedbackPanelAdapter feedbackPanel() {
        return feedbackPanel;
    }

    public FormPanelAdapter setName(String value) {
        nameField.setValue(value);
        return this;
    }

    public FormPanelAdapter setEmail(String value) {
        emailField.setValue(value);
        return this;
    }

    public ActionResult submit() {
        return submitButton.click();
    }
}

Wenn man alle zu testenden Anwendungsbestandteile entsprechend abgebildet hat, kann man auf diese Weise aus:


open("http://localhost:8080");

$("input[wicket\:id='name']").setValue("John Doe");
$("input[wicket\:id='email']").setValue("john@example.com");
$("input[type='submit']").click();

$("div[wicket\:id='feedback']").shouldHave(text("Hello John Doe!"));

diesen Test ableiten:


CurrentPage.open("http://localhost:8080")
        .expect(FirstPage::new)</p>
        .formPanel()
        .setName("John Doe")
        .setEmail("john@example.com")
        .submit()

        .expectNewPage(FirstPage::new)
        .formPanel()
        .feedbackPanel()
        .expectMessageAt(0, message -> {
            message.hasText("Hello John Doe!")
                .isInfo();
        })
        .expectNoMessageAt(1);

Wenn man jetzt für die Komponenten in den Wicket-Komponenten Stringkonstanten benutzt und diese in den Adaptern referenziert, kann man viel einfacher Komponenten umbauen und ohne größeren Aufwände die Adapter und damit die Tests anpassen. Man läuft nicht mehr Gefahr, das man an mehreren Stellen irgendwelche Selenide-Selektoren anpassen muss, damit die Tests wieder funktionieren.