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:
- It wraps your tester. It never subclasses
WicketTester, so any tester works, including one your test framework hands you, and the tester stays usable next to it. - Same semantics as WicketTester. Every action and assertion finds one component and calls the WicketTester or FormTester method that does the job. Wicket’s behaviour is never re-implemented.
- Exactly one by default. A locator used for an action or an assertion has to match exactly one component. Anything else fails with a message that says what was searched and what was found.
- Framework-agnostic. Failures are
AssertionErrors, so it works with JUnit, TestNG or anything else. - Test scope only. It is a test library. Production code that needs to walk the component tree
uses Wicket’s own
visitChildren.
Installation#
Add wicket-testkit in test scope. It needs Apache Wicket 10 and Java 17 or newer.
<dependency>
<groupId>dev.jbaby</groupId>
<artifactId>wicket-testkit</artifactId>
<version>0.1.0</version>
<scope>test</scope>
</dependency>
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.
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:idLocators 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#
| Refinement | Matches 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
AssertionErrorwith 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.clickLinkdoes:Link,AjaxLink(through Ajax),AjaxFallbackLink,SubmitLink,AjaxSubmitLink,BookmarkablePageLinkand the other link types it knows. kit.ajax(component, event)- Fires an Ajax event on a component, as
WicketTester.executeAjaxEventdoes.
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-testkit | WicketTester | Checks |
|---|---|---|
assertLabel(label, "text") | assertLabel | a label’s text |
assertVisible(c) | assertVisible | visible in hierarchy |
assertInvisible(c) | assertInvisible | not visible in hierarchy |
assertEnabled(c) | assertEnabled | enabled in hierarchy |
assertDisabled(c) | assertDisabled | not enabled in hierarchy |
assertModelObject(c, value) | assertModelValue | the model object; null for none |
assertFeedback(panel, "msg"…) | assertFeedback | a feedback panel shows these messages, in any order |
assertOnAjaxResponse(c) | assertComponentOnAjaxResponse | the 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
WicketTestermethod 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.setValuedoes. select(field, index)- Selects the choice at an index, from zero, as
FormTester.selectdoes. 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
FormTesterbehind 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:
- Until the tester handles another request,
kit.form(…)returns the same helper for the same form, with the values set so far. A page object can fill in a form across several methods, each callingkit.form(…). - Once a submit, an Ajax event or any other request was sent, the old helper fails with
the tester has handled a request since this form helper was created; call form again. Callkit.form(…)again; the new helper starts from the form’s values as they are now.
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:
- A disabled field. Wicket ignores its input, so
setandselectfail withthe component is not enabled in hierarchy. So doessubmitthrough a disabled button, which nobody could click. - A hidden field or button. It fails with
the component is not visible in hierarchy. - A field with a
FormComponentUpdatingBehavior.FormTester.selectsends a request for it at once, which throws away every value set so far.selectfails and says so; if you mean it, select throughformTester():
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.
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 criterion | What the near miss says |
|---|---|
| type | is a NoOpLink, is an AjaxLink |
visible | is not visible |
enabled | is not enabled |
inside | is not inside a Panel with id "address" |
inRowOf | is not in the row of Order[id=42, customer=Globex] |
withModelObject | has model object "ACME", or has a model object that cannot be read |
matching | does not match "is required" |
withId | has 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#
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:
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:
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:
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,insideorinRowOf, and useassertCountfor “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
Loopitems and plainRepeatingViewchildren are not rows.- Ajax fallback buttons
AjaxFallbackButtonextendsButton, sosubmit(button)submits through it without Ajax.WicketTester, notBaseWicketTester- The assertions exist only on
WicketTester, so that is whatWicketTestkit.oftakes. - Plain articles
- Descriptions put “an” before any vowel letter, so a
UserPanelcomes 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>#
| Method | Does |
|---|---|
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#
| Method | Does |
|---|---|
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, assertDisabled | Their 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#
| Method | Does |
|---|---|
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.