wicket-testkit

User guide · version 0.1.0

Testing Wicket pages with locators

Everything about wicket-testkit: how to find components, act on them, fill in forms, read the failure messages and build page objects. It assumes you know Apache Wicket and its WicketTester.

What it is for#

WicketTester addresses components by string paths such as "orders:1:customer". Such a path encodes the whole component tree on the way to the component. It breaks as soon as someone wraps a container around the list or the rows change order, and in the second case it may silently check the wrong row. It also tells the reader nothing about what the test means.

wicket-testkit lets a test locate a component by what it is: its type, its wicket:id, its model object, its state, the component it sits in, or the row of a list it belongs to. Then it acts and asserts on that component through the same WicketTester methods you use today.

Five principles shape it:

Installation#

Add wicket-testkit in test scope. It needs Apache Wicket 10 and Java 17 or newer.

pom.xml
<dependency>
    <groupId>dev.jbaby</groupId>
    <artifactId>wicket-testkit</artifactId>
    <version>0.1.0</version>
    <scope>test</scope>
</dependency>
build.gradle.kts
testImplementation("dev.jbaby:wicket-testkit:0.1.0")

It brings no dependencies of its own. It uses the wicket-core and wicket-tester your project already has, so your Wicket version wins. Since Wicket 10, the tester classes live in their own artifact, org.apache.wicket:wicket-tester; make sure it is on your test classpath.

Getting started#

Two imports do it: the locate factory, and WicketTestkit, which wraps your tester.

OrdersPageTests.java
import static dev.jbaby.wicket.testkit.locator.Locator.locate;

import dev.jbaby.wicket.testkit.WicketTestkit;

class OrdersPageTests {

    WicketTester tester = new WicketTester(new MyApplication());
    WicketTestkit kit = WicketTestkit.of(tester);

    @Test
    void shipsAnOrder() {
        tester.startPage(OrdersPage.class);

        kit.click(locate(AjaxLink.class).withId("ship").inRowOf(globex));

        kit.assertLabel(locate(Label.class).withId("status").inRowOf(globex), "shipped");
        kit.assertOnAjaxResponse(locate(Label.class).withId("status").inRowOf(globex));
    }
}

Start pages and components with the tester as you always have. The testkit always works on the page the tester rendered last, looked up at the moment you call it, so after a click that renders another page, the next locator searches the new one.

Locating components#

A Locator describes components. It starts with a type, and refinements narrow it down. A component matches when it meets every criterion.

locate(Label.class)                       // every Label, subclasses included
locate(Label.class).withId("customer")    // ... with this wicket:id
locate(Component.class).withId("save")    // anything with this wicket:id

Locators are immutable. Every refinement returns a new locator and leaves the original as it was, so you can keep a base locator in a field and refine it where you use it.

Locator<Label> customer = locate(Label.class).withId("customer");

kit.assertLabel(customer.inRowOf(acme), "ACME");
kit.assertLabel(customer.inRowOf(globex), "Globex");

Refinements#

RefinementMatches components
withId("customer")with this wicket:id
withModelObject("ACME")whose model object equals the argument; null matches components whose model object is null
visible()that are visible in hierarchy: they and all their parents are visible
enabled()that are enabled in hierarchy
inside(otherLocator)with an ancestor that the other locator matches, anywhere above them
inRowOf(order)in the row of a list whose element equals the argument
inRowOf(Order.class, predicate, "…")in a row whose element is of that type and passes the predicate
matching(predicate, "…")that the predicate accepts

matching is the way out for anything else. Its description is required, because failure messages show it; it completes the sentence “a TextField that …”:

locate(TextField.class).matching(FormComponent::isRequired, "is required")
locate(Label.class).matching(label -> label.getDefaultModelObjectAsString().isBlank(), "is empty")

inside takes another locator, so you can scope a search to a panel, a form or a dialog:

locate(Button.class).withId("save").inside(locate(Panel.class).withId("address"))

A locator doesn’t filter by visibility unless you call visible(). That way the same locator works for asserting that something is hidden. When a page has the same component twice, once hidden, add visible() to pick the one that shows.

Model objects and predicates come last. A locator reads a component’s model object, and runs your predicate on it, only once the component has met the type and every other criterion, whatever order you wrote them in. Models of unrelated components are never loaded, and a predicate only sees the components it was written for.

A model object that can’t be read doesn’t match. That happens under a CompoundPropertyModel, which gives every component without a model of its own a model for the property of its id; if the object has no such property, reading it throws.

Rows#

A row is a ListItem: what ListView renders for each element of its list. RefreshingView and DataView render an Item, which extends ListItem, so they are covered too, and so are PropertyListView, PageableListView and GridView. The row’s element is its model object.

locate(Link.class).withId("details").inRowOf(order)

inRowOf(order) compares with equals. Records and value objects work out of the box. Entities that a LoadableDetachableModel loads again for every request often equal only themselves; match their row with a predicate instead. The description completes “in the row of an Order that …”:

locate(Label.class).withId("customer")
    .inRowOf(Order.class, o -> o.getId().equals(orderId), "has id " + orderId)

Items of Loop and the children of a plain RepeatingView are not rows. Locate them with inside or matching.

Resolving#

The testkit resolves locators for you. To use a locator on its own, for example in a helper that gets a container, give it a root: a page or any other container. The root itself is never a candidate, only its descendants are.

one(root)
The one matching component, typed as the locator’s type. No match or several matches throw an AssertionError with a message that explains.
all(root)
Every match in tree order (depth first, a parent before its children), possibly empty.
existsIn(root)
Whether anything matches.

The locating layer, dev.jbaby.wicket.testkit.locator, uses nothing from wicket-tester, so it works on any component tree.

Descriptions#

A locator describes itself in plain words, and every failure message uses that description. toString() returns it.

locate(Label.class).withId("customer").inRowOf(order)
// a Label with id "customer" in the row of Order[id=42, customer=Globex]

locate(Button.class).visible().enabled().inside(locate(Panel.class).withId("address"))
// a visible, enabled Button inside a Panel with id "address"

The parts always come in the same order: visible and enabled before the type, then id, model object, matching descriptions, inside and row. Strings are quoted, other values appear as String.valueOf shows them, so give your row elements a readable toString.

Clicking and Ajax#

kit.click(link)
Clicks a link, as WicketTester.clickLink does: Link, AjaxLink (through Ajax), AjaxFallbackLink, SubmitLink, AjaxSubmitLink, BookmarkablePageLink and the other link types it knows.
kit.ajax(component, event)
Fires an Ajax event on a component, as WicketTester.executeAjaxEvent does.
kit.click(locate(Link.class).withId("details").inRowOf(order));
kit.ajax(locate(WebMarkupContainer.class).withId("row").inRowOf(order), "dblclick");

Where WicketTester would quietly do nothing, the testkit fails: ajax on a component that has no Ajax behavior for the event throws an AssertionError that says so.

kit.ajax sends no form input. For a form field with an AjaxFormComponentUpdatingBehavior, it would leave the field’s model empty. Use the form helper’s ajax, which sends the new value.

Asserting#

Each assertion finds one component and calls the WicketTester assertion next to it in the table.

wicket-testkitWicketTesterChecks
assertLabel(label, "text")assertLabela label’s text
assertVisible(c)assertVisiblevisible in hierarchy
assertInvisible(c)assertInvisiblenot visible in hierarchy
assertEnabled(c)assertEnabledenabled in hierarchy
assertDisabled(c)assertDisablednot enabled in hierarchy
assertModelObject(c, value)assertModelValuethe model object; null for none
assertFeedback(panel, "msg"…)assertFeedbacka feedback panel shows these messages, in any order
assertOnAjaxResponse(c)assertComponentOnAjaxResponsethe last Ajax response rendered it
assertCount(locator, n)(its own)exactly n components match, zero included

assertLabel takes a locator of labels and assertFeedback one of feedback panels, so the compiler catches a mix-up.

kit.assertLabel(locate(Label.class).withId("total"), "42.00");
kit.assertInvisible(locate(Link.class).withId("ship").inRowOf(shippedOrder));
kit.assertFeedback(locate(FeedbackPanel.class).visible(), "Customer is required.");
kit.assertCount(locate(Label.class).withId("customer"), 3);

assertInvisible needs the component to exist, hidden. To assert that nothing matches at all, use assertCount(locator, 0).

For anything the testkit doesn’t wrap#

kit.one(locator)
The located component, typed, for your own checks.
kit.pathOf(locator)
Its page-relative path, which every WicketTester method takes.
TextField<?> customer = kit.one(locate(TextField.class).withId("customer"));
assertThat(customer.isRequired()).isTrue();

tester.assertComponent(kit.pathOf(locate(Panel.class).withId("details")), DetailsPanel.class);
tester.assertFeedbackMessages(new ComponentFeedbackMessageFilter(customer), "Customer is required.");

Forms#

kit.form(locator) returns a FormHelper for the located form. It wraps a FormTester, starting from the form’s current values, and finds fields, buttons and any other component inside that form, however deep they are nested. You never write a form-relative path.

kit.form(locate(Form.class).withId("order"))
    .set(locate(TextField.class).withId("customer"), "Globex")
    .select(locate(DropDownChoice.class).withId("country"), 1)
    .submit();
set(field, "input")
Sets a field’s input as a user would type it, as FormTester.setValue does.
select(field, index)
Selects the choice at an index, from zero, as FormTester.select does.
ajax(component, event)
Fires an Ajax event inside the form, sending the values set so far.
submit() / submit(button)
Submits the form, without or through a button.
formTester()
The FormTester behind it, for files, multiple selections and check boxes as booleans.

Submitting#

submit(button) submits through the button, as FormTester.submit(Component) does. For an AjaxButton or AjaxSubmitLink that means an Ajax submit with the values set so far, so there is no separate method for it.

kit.form(locate(Form.class).withId("order"))
    .set(locate(TextField.class).withId("customer"), "Globex")
    .submit(locate(AjaxButton.class).withId("save"));

kit.assertOnAjaxResponse(locate(FeedbackPanel.class).visible());

AjaxFallbackButton extends Button, not AjaxButton, so FormTester submits through it without Ajax.

Ajax on fields#

An AjaxFormComponentUpdatingBehavior reads the field’s new input from the request. Set or select the value, then fire the event through the form helper:

kit.form(locate(Form.class).withId("order"))
    .select(locate(DropDownChoice.class).withId("country"), 1)
    .ajax(locate(DropDownChoice.class).withId("country"), "change");

kit.assertLabel(locate(Label.class).withId("shipping"), "Shipping to Germany");

One helper per request#

A FormTester puts its values into the tester’s current request. So a form helper lives for one request:

void enterCustomer(String name) {
    kit.form(order).set(locate(TextField.class).withId("customer"), name);
}

void save() {
    kit.form(order).submit(locate(Button.class).withId("save"));  // same helper, sends the name
}

What fails, and why#

Where Wicket or FormTester would drop a value without a word, the form helper fails instead:

kit.form(locate(Form.class).withId("order")).formTester().select("speed", 1);

Failure messages#

Every failure is an AssertionError. The messages follow a fixed format, specified and tested so it stays stable.

Nothing matches#

The message names the criteria and the root. If the locator has at least two criteria (its type counts as one), it lists the near misses: components that meet every criterion but one, each with what differs.

Expected exactly one match for a Label with id "customr" in the row of Order[id=42, customer=Globex] in OrdersPage, but found none.
Near misses (all criteria but one):
  - orders:1:customer (has id "customer")
  - orders:1:status (has id "status")

Near misses come in the order of the criterion they miss, most telling first; within each, in tree order. At most five are shown, then how many more there are.

Missed criterionWhat the near miss says
typeis a NoOpLink, is an AjaxLink
visibleis not visible
enabledis not enabled
insideis not inside a Panel with id "address"
inRowOfis not in the row of Order[id=42, customer=Globex]
withModelObjecthas model object "ACME", or has a model object that cannot be read
matchingdoes not match "is required"
withIdhas id "status"

A locator that misses the type or any other cheap criterion is not asked for its model object or predicate, so such components count as near misses only for locators without those. Building the message never throws: a criterion that fails while near misses are collected simply counts as missed.

Several match#

Expected exactly one match for an AjaxLink with id "ship" in OrdersPage, but found 3:
  - orders:0:ship
  - orders:1:ship
  - orders:2:ship

At most ten paths are listed, then how many more there are. assertCount lists its matches the same way, starting with Expected 2 matches for ….

WicketTester’s own failures#

When the WicketTester or FormTester method fails, the locator’s description and the component’s path go in front of its message, and its error stays the cause:

a Label with id "customer" in the row of Order[id=42, customer=Globex] (at orders:1:customer): expected: <ACME> but was: <Globex>

When WicketTester’s error is opentest4j’s AssertionFailedError, as for assertLabel and assertModelObject, the testkit throws one too, with the same expected and actual values, so your IDE still offers its diff. A component that is not visible in hierarchy, where WicketTester would fail with a NullPointerException or “does not exist”, gets a clear message instead:

a Label with id "secret" (at vault:secret): the component is not visible in hierarchy

Any exception that is not an AssertionError, such as one thrown by your page’s onClick, passes through unchanged: it is a bug in the application, not a failed check.

Testing a panel#

Start a panel or any other component on its own with startComponentInPage, and locate inside it as on any page:

tester.startComponentInPage(new CounterPanel("counter"));

kit.click(locate(AjaxLink.class).withId("increment"));
kit.assertLabel(locate(Label.class).withId("count"), "1");

Everything inside the started component can be located, but not the component itself: WicketTester puts the component’s id in front of every path it gets, and no path can then address the component.

Page objects#

Locators are values, which makes page objects short. Keep the locators in the page object, refine them per row, and let the test speak the language of your domain:

OrdersPageObject.java
class OrdersPageObject {

    private final WicketTestkit kit;

    OrdersPageObject(WicketTestkit kit) {
        this.kit = kit;
    }

    Row rowOf(Order order) {
        return new Row(order);
    }

    class Row {

        private final Order order;

        Row(Order order) {
            this.order = order;
        }

        void ship() {
            kit.click(locate(AjaxLink.class).withId("ship").inRowOf(order));
        }

        void assertStatus(String status) {
            kit.assertLabel(locate(Label.class).withId("status").inRowOf(order), status);
        }
    }
}
@Test
void shipsAnOrder() {
    tester.startPage(OrdersPage.class);
    var ordersPage = new OrdersPageObject(WicketTestkit.of(tester));

    ordersPage.rowOf(globex).ship();

    ordersPage.rowOf(globex).assertStatus("shipped");
}

Generic Wicket components such as TextField and Form make locate return a raw type: locate(TextField.class) is a Locator<TextField>. Passed straight to a method, that is fine. Kept in a field, it shows a raw-type warning; add @SuppressWarnings("rawtypes") to the field, or declare it as Locator<?> where the method takes any locator, as click and most assertions do.

With Spring Boot#

Any WicketTester works, whoever created it. With the dev.jbaby Wicket Spring Boot starter’s test module, @WicketTest hands each test a tester running your application; wrap it:

@WicketTest
class OrdersPageTests {

    @Test
    void listsOpenOrders(WicketTester tester) {
        var kit = WicketTestkit.of(tester);
        tester.startPage(OrdersPage.class);

        kit.assertCount(locate(Label.class).withId("customer"), 3);
    }
}

Limits#

Exactly one
Every action and assertion needs its locator to match exactly one component. Narrow it with visible(), withId, inside or inRowOf, and use assertCount for “none”.
Always the last rendered page
Locators are resolved against the page the tester rendered last. To search a part of it, use inside.
The component under test itself
After startComponentInPage, the started component can’t be located, only what is inside it.
Rows are list items
Loop items and plain RepeatingView children are not rows.
Ajax fallback buttons
AjaxFallbackButton extends Button, so submit(button) submits through it without Ajax.
WicketTester, not BaseWicketTester
The assertions exist only on WicketTester, so that is what WicketTestkit.of takes.
Plain articles
Descriptions put “an” before any vowel letter, so a UserPanel comes out as “an UserPanel”.
Wicket 10 only
Wicket 9 and older are not supported; Wicket 11 will follow.

API reference#

The Javadoc has every detail and an example per method. In short:

Locator<T>#

MethodDoes
static locate(Class<T> type)Starts a locator for that type, subtypes included.
withId(String id)Narrows to a wicket:id.
withModelObject(Object modelObject)Narrows to an equal model object, or null.
visible(), enabled()Narrow to components visible or enabled in hierarchy.
inside(Locator<?> ancestor)Narrows to components with a matching ancestor.
inRowOf(Object element)Narrows to the row of an equal element.
inRowOf(Class<E>, Predicate<? super E>, String)Narrows to the row of an element that passes the predicate.
matching(Predicate<? super T>, String)Narrows to components the predicate accepts.
one(MarkupContainer root)The one match, or an AssertionError.
all(MarkupContainer root)Every match, in tree order.
existsIn(MarkupContainer root)Whether anything matches.
toString()The description.

WicketTestkit#

MethodDoes
static of(WicketTester tester)Wraps a tester.
click(Locator<?>)clickLink
ajax(Locator<?>, String event)executeAjaxEvent; fails without a behavior for the event
form(Locator<? extends Form>)A FormHelper for the form
assertLabel(Locator<? extends Label>, String)assertLabel
assertVisible, assertInvisible, assertEnabled, assertDisabledTheir WicketTester namesakes
assertModelObject(Locator<?>, Object)assertModelValue
assertFeedback(Locator<? extends FeedbackPanel>, Serializable…)assertFeedback
assertOnAjaxResponse(Locator<?>)assertComponentOnAjaxResponse
assertCount(Locator<?>, int)How many match
one(Locator<T>)The located component, typed
pathOf(Locator<?>)Its page-relative path

FormHelper#

MethodDoes
set(Locator<? extends FormComponent>, String)FormTester.setValue
select(Locator<? extends FormComponent>, int)FormTester.select
ajax(Locator<?>, String event)executeAjaxEvent with the values set so far
submit()FormTester.submit()
submit(Locator<?> button)FormTester.submit(Component), through Ajax for Ajax buttons
formTester()The FormTester itself

Credits#

wicket-testkit builds on the idea of otto-de/enhanced-wickettester (Apache 2.0), which selected Wicket components by matchers instead of paths, back in Wicket 7. wicket-testkit is a from-scratch rewrite for Wicket 10 and shares no code with it: it wraps WicketTester instead of subclassing it, needs no TestNG, Hamcrest or commons-lang, and adds the row shortcut and the failure messages. The word “locator” is borrowed from Playwright.

Apache Wicket is a trademark of the Apache Software Foundation; this project is not part of it. wicket-testkit is licensed under the Apache License 2.0.