
Bei der Entwicklung mit Wicket können Komponenten bereits mit den nativen Tools getestet werden (“Tests mit WicketTester”). Diese Tests operieren hauptsächlich auf Request-Ebene und testen nicht das tatsächliche Verhalten im Browser. Allerdings sind sie schnell und eignen sich gut für grundlegende Komponentenprüfungen.
Oberflächentests bilden einen wesentlichen Baustein für robuste Webanwendungen. Sie stellen sicher, dass die Benutzeroberfläche zusammen funktioniert und dass clientseitige Funktionen ebenfalls validiert werden können.
Lange Zeit war Selenium der Standard, wurde jedoch durch das benutzerfreundlichere Selenide abgelöst. Der Artikel zeigt, wie man passende Abstraktionen findet, um bessere Tests und einfachere Refactoring-Möglichkeiten zu erreichen.
Bausteine für Wicket-Tests
Die Grundlage bildet ein Projekt aus dem Apache Wicket Quickstart, ergänzt um Selenide.
Beispielanwendung
Die Anwendung besteht aus zwei Seiten. Die erste Seite enthält ein Formular mit zwei Feldern und einem erforderlichen Feld sowie Links zur Navigation zwischen den Seiten. Eine selbst geschriebene Komponente beherbergt das Formular.
FirstPage:
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);
}
});
}
}
SecondPage:
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
Selenide ist einfach zu integrieren. Man startet die Anwendung und fernsteuert die Seite der laufenden Anwendung. Ein einfacher Test 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!"));
Dies erscheint einfach und wäre für diese Anwendung ausreichend. Mit wachsender Komplexität werden die Selektoren jedoch schwieriger zu beschreiben. Wicket-Entwickler können hier von dem Komponentenansatz auf unerwartete Weise profitieren.
Wicket-Anpassungen für Selenide
Wicket kann den vollständigen Pfad einer Komponente in ein Attribut eines HTML-Tags rendern. Dies ermöglicht eine eindeutige Adressierung jeder Komponente, auch wenn sie mehrfach auf der Seite vorhanden ist. Zusätzlich kann eine kleine Erweiterung erstellt werden, die die aktuelle Seite als Kommentar im HTML anzeigt. Dies hilft zu überprüfen, auf welcher Seite man sich befindet.
In der WicketApplication-Klasse in der init-Methode müssen folgende Zeilen ergänzt werden:
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:
public class PageClassAsHeaderComment extends HeaderItem {
@Override
public Iterable<?> getRenderTokens() {
return Collections.emptyList();
}
@Override
public void render(Response response) {
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();
}
}
Das HTML der laufenden Anwendung enthält im Kopfbereich den Klassennamen der Seite:
<head><!-- WicketPage(de.conceptpeople.ui.FirstPage) -->
<meta charset="utf-8" />
<title>Form Page</title>
Jede Komponente hat im data-wicket-path-Attribut ihren Komponentenpfad:
<input type="text" wicket:id="name" id="name" value="" name="p::name" data-wicket-path="formPanel_form_name"/>
Bei Komponenten mit eigenem Markup finden sich Kommentare auf der Seite:
<!-- MARKUP FOR de.conceptpeople.ui.FormPanel BEGIN -->
Obwohl man Komponenten durch das data-wicket-path-Attribut eindeutig adressieren kann, ermöglicht der Kommentar eine
Überprüfung, ob es sich um die erwartete Komponente handelt, sofern sie ein eigenes Markup besitzt.
Wicket-Adapter
Beim Erstellen von Adaptercode folgt man grob dem “Page Object”-Pattern, sollte aber darüber hinaus auch die Komponentenstruktur nachempfinden.
Man benötigt nur Zugriff von außen auf das durch den Nutzer auslösbare Verhalten: Eingabefelder befüllen, Links und Buttons klicken, Inhalte überprüfen. Für wiederkehrendes Verhalten können Funktionen angelegt werden, die mehrere Interaktionen bündeln.
CurrentPage-Abstraktion:
public class CurrentPage {
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();
}
}
Die CurrentPage-Abstraktion enthält bereits die erste Prüfung, ob man sich auf der richtigen Seite befindet. Zudem
existiert eine Factory-Methode zum Erstellen von Komponenten-Adaptern.
WicketPage-Abstraktion:
public abstract class WicketPage {
private final CurrentPage page;
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. bei Verwendung von <wicket:container>), wird eine
Basisklasse benötigt, die nur die notwendigsten Funktionen abbildet:
public abstract class WicketContainer<T extends WicketContainer<T>> {
private final CurrentPage page;
private final WicketPath path;
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 Standardkomponenten können Prüfungen frühzeitig durchgeführt 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;
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 kann man für die erste Seite einen entsprechenden Adapter bauen:
public class FirstPage extends WicketPage {
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);
Obwohl der erste Test sehr kurz aussieht, werden hier bereits diverse Prüfungen vorgenommen:
- Es wird geprüft, ob man sich wirklich auf dieser Seite befindet
- Es wird sichergestellt, dass die Seite ein FormPanel und ein Link enthält
FormPanel-Adapter
Der Adapter für das FormPanel folgt einem wiederkehrenden Muster:
public class FormPanelAdapter extends WicketComponent<FormPanelAdapter> {
private final FeedbackPanelAdapter feedbackPanel;
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();
}
}
Verbesserter Test
Wenn man alle zu testenden Anwendungsbestandteile entsprechend abgebildet hat, kann man aus dem einfachen Test:
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 verbesserten Test ableiten:
CurrentPage.open("http://localhost:8080")
.expect(FirstPage::new)
.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);
Fazit
Durch die Verwendung von Stringkonstanten in den Wicket-Komponenten und deren Referenzierung in den Adaptern kann man Komponenten viel einfacher umbauen. Man kann ohne großen Aufwand die Adapter und damit die Tests anpassen. Man läuft nicht mehr Gefahr, dass man an mehreren Stellen Selenide-Selektoren anpassen muss, damit die Tests wieder funktionieren.

