Skip to content

Testing your UIs

Unit tests of your view models cover the logic of an action (Testing). They do not tell you what the user sees: that the listing really shows four rows, that the Save button of a new product creates it, that a field an admin may edit is read-only for everybody else. For that, Mateu ships a test harness: it runs Mateu in your test’s process, opens a route the way a renderer does and lets the test read the screen and act on it.

  • No browser, no server, no port. The harness calls the same entry point the /mateu/v3/sync endpoint calls; a test runs in milliseconds.
  • What the renderers receive. The test reads the very answer every renderer draws (Vaadin, Redwood, the app, the IDE), so a passing test holds on all of them.
  • A user’s gestures. open, read field/button/listing, set a value, press a button, search, open or select a row, follow the menu — and the screen follows what the server answers: an app shell to its home, a CRUD to its listing and to /new or /{id}/edit, a NavigateTo, a drawer or dialog stacked over the page, the listing’s first search.
  • An identity. Open a screen as a user with roles to test @EyesOnly, @ReadOnlyUnless, @DisabledUnless and YAML access: rules.

Add io.mateu:mateu-test (managed by io.mateu:mateu-bom) with test scope, next to JUnit:

<dependency>
<groupId>io.mateu</groupId>
<artifactId>mateu-test</artifactId>
<version>${mateu.version}</version>
<scope>test</scope>
</dependency>

It needs no Spring, Quarkus or Micronaut context: the harness builds Mateu’s own beans and the beans you hand it. Failures are plain AssertionErrors, so it works with JUnit, AssertJ or anything else.

The starter’s Products CRUD, tested as a user uses it:

import io.mateu.test.MateuTest;
class ProductsUiTest {
@Test
void aNewProductIsSaved() {
try (var mateu = MateuTest.builder().scan("com.example.app").start()) {
var products = mateu.open("/products");
assertEquals(4, products.listing().total());
var form = products.button("New").press(); // the crud's /new form
form.field("id").set("P-100");
form.field("Name").set("Tamper"); // by id or by label
form.field("price").set(19.5);
form.field("status").set(ProductStatus.Available);
form.button("Save").press();
assertEquals("Tamper", ProductStore.INSTANCE.findById("P-100").orElseThrow().name());
}
}
}

scan("com.example.app") serves every @UI class of the package, as the annotation processor registers them in the app (ui(Shell.class, …) lists them instead). The specs/ui YAML on the class path is served too, so a project whose shell and routes are YAML needs no class at all; specs(Path) reads it from another directory. The generated projects of the New project wizards include this test.

A field is found by its id or its label, a button by its label or the action it runs. When it is not there, the failure lists what there is (No field "nme" on /products/new. Fields: [id, name, price, status]).

JavaC#Python
Where it isroute(), title(), pageTitle()Route, Title, PageTitleroute, title, page_title
The app shell’s menumenu(), openMenu("Sales > Orders")Menumenu
Fieldsfields(), field(idOrLabel), hasField(…)Fields, Field(…), HasField(…)fields, field(…), has_field(…)
A field’s lookvalue(), text(), readOnly(), required(), disabled(), options(), dataType(), stereotype()Value, Text, ReadOnly, Required, Options, DataType, Stereotypevalue, text, read_only, required, options, data_type, stereotype
Typingfield(…).set(value), clear()Field(…).Set(value)field(…).set(value)
Buttonsbuttons(), button(labelOrAction), enabled(), press()Buttons, Button(…), Enabled, Press()buttons, button(…), enabled, press()
The listinglisting().columns(), rows(), total(), row(i), row("text"), search(…), filter(id, v), page(n), sortBy(…)Listing().Columns, Rows, Total, Row(…), Search(…), Filter(…), GoToPage(n)listing().columns, rows, total, row(…), search(…), filter(…), page(n)
A rowget(column), open(), select()[column], Open(), Select()[column], open(), select()
What the last gesture showedmessages(), lastMessage(), commands()Messages, LastMessage, Commandsmessages, last_message, commands
OverlayshasOverlay(), overlayTitle(), closeOverlay()HasOverlay, OverlayTitle, CloseOverlay()has_overlay, overlay_title, close_overlay()
Anything elseshows(text), state(), component() (the wire JSON), run(actionId, params), navigate(route)Shows, State, Component, Run, Navigateshows, state, component, run, navigate

mateu.exchange(request, identity) is the raw door: one request, the answer as wire JSON.

var visitor = mateu.open("/greeting");
assertFalse(visitor.hasField("note")); // @EyesOnly(roles = "admin")
assertTrue(visitor.field("code").readOnly()); // @ReadOnlyUnless(roles = "admin")
assertFalse(visitor.button("reset").enabled()); // @DisabledUnless(roles = "admin")
var admin = mateu.open("/greeting", Identity.withRoles("admin"));
assertTrue(admin.hasField("note"));

Identity.withClaims(Map.of("sub", "ada", "scope", "orders:write")) reads token claims the way Mateu reads a token your framework verified; Identity.of(CallerIdentity) spells every dimension out.

The harness runs the server side of a UI. What a renderer runs in the browser is reported as declared, never evaluated:

  • Client-side rules — a field shown, hidden or disabled by an expression. disabled() is true only for an unconditional @Disabled or a @DisabledUnless the caller fails.
  • REST sources called from the browser — pressing a button whose action is a direct restAction fails with a clear message; a proxied source runs on the server and works.
  • Client-side validation — the server re-checks the same constraints, so a save with an empty required field answers “There are validation errors”, which is what the test reads.
  • Looks — layout, styles and themes. Screenshot tests belong to the renderers’ own suites.

One harness at a time per JVM in Java (the core keeps its bean provider in a static holder): do not run harness tests in parallel threads. A Screen is one user’s tab and is not thread-safe.

  • Testing — unit tests of actions and view models
  • Security — the access rules the identity drives