Blog

A more Playwright-like API for Vaadin browserless tests

By  
Matti Tahvonen
Matti Tahvonen
·
On May 28, 2026 1:25:01 PM
·
In Product

In Vaadin 25.1 the browserless-test module became part of the Apache 2 licensed open core framework. We believe it's on track to become a key part of the agentic-coding setup for Vaadin apps. In the upcoming Vaadin 25.2 we're bringing in API additions that make its usage more flexible and intuitive.

  • drops the mandatory test base class — your test class extends whatever your project conventions ask for (Spring, JUnit base of your own, nothing at all).
  • collapses query and interaction into one stepfindButton() returns a ready-to-use type for testing instead of a raw component reference you have to wrap with test(...).
  • makes multi-user and multi-window first-class — the three-level context structure (app → user → ui) is the same surface a real Vaadin app has, so simulating two users sharing a session or one user opening two windows is just driving the UIs via two different handles.

The existing API continues to work; adoption of the new mode can happen iteratively. API can be tested using Vaadin 25.2.0-beta1. This post walks through what the new style API looks like and why we think this direction is worth committing to.

A short example

Here's a small test in the new shape — no class hierarchy, the try-with-resources owns the Vaadin environment for the test:

@Test
void signIn_validCredentials_navigatesHome() {
 try (var app = BrowserlessApplicationContext.create("com.acme.myapp")) {
 var ui = app.newUser().newWindow();
 ui.navigate(LoginView.class);

 ui.findTextField().withLabel("Email").setValue("ada@example.com");
 ui.findTextField().withLabel("Password").setValue("hunter2");
 ui.findButton().withCaption("Sign in").click();

 Assertions.assertInstanceOf(HomeView.class, ui.getCurrentView());
 }
}

A few things to notice: no extends BrowserlessTest, no field-grabbing through a view reference, no test(component).setValue(...) wrapping step between the query and the interaction. The two key types that make this work are introduced next.

Contexts

A Browserless test now has a clear three-level structure that mirrors what a real Vaadin app has:

Concept

Type

What it represents

Application

BrowserlessApplicationContext

the Vaadin service + routes — shared across users. There are SpringBrowserlessApplicationContext and QuarkusBrowserlessApplicationContext factories to help working with the common dependency injection frameworks.

User

BrowserlessUserContext

one user session with their own security state

UI

BrowserlessUIContext

one open browser window for that user

A test reaches them through a straight three-step chain:

try (var app = BrowserlessApplicationContext.create(routes)) {
 var ui = app.newUser().newWindow();
 ui.navigate(MyView.class);

 // test code uses `ui` to query and act
}

ui (a BrowserlessUIContext) is the surface tests interact with: it's where you call findButton(...), findTextField(...), navigate(...), fireShortcut(...). It's also where Vaadin's thread-locals are activated under the hood when you switch between two ui instances — so multi-user tests don't have to think about thread-local state at all.

Multi-user is just two windows

The three-level structure makes scenarios that previously required either manual session juggling or an actual browser become natural:

try (var app = SpringBrowserlessApplicationContext.createSecured(routes, ctx)) {
 var alice = app.newUser("alice", "ADMIN").newWindow();
 var bob = app.newUser("bob", "USER").newWindow();

alice.navigate(AdminView.class); // OK — has the role

 // Navigating to admin view as regular user should through an exception
 Assertions.assertThrows(SecurityException.class,
 () -> bob.navigate(AdminView.class));

 // Interleave freely — calling any DSL method on `alice` or `bob`
 // re-activates that user's security context automatically.
 alice.findButton().withText("Add user").click();
 bob.findTextField().withLabel("Search users").setValue("alice");
}

The same pattern lets a single user open multiple windows:

var alice = app.newUser();
var primary = alice.newWindow();
var secondary = alice.newWindow();

You can use this even when "multi-user" or "multi-window" isn't the point of the test — it just turns out to be the cleanest way to compose a fresh isolated environment per test method.

No mandated superclass

Notice what isn't in the snippets above: there's no extends BrowserlessTest, no @ViewPackages annotation requiring you to pre-list your views. The contexts handle their own lifecycle, so your test class can extend whatever your test conventions ask for (Spring's @SpringBootTest, your own base class, nothing at all) without fighting the test framework. The library's lifecycle assumptions stay inside the try-with-resources.

Locators

Once you have a ui, the locator API replaces the older two-step "find then wrap" pattern. The old shape looked like:

// Old: query returns a Component, test() wraps it for interaction
TextField field = $(TextField.class).withLabel("Email").first();
test(field).setValue("a@b.com");

The new shape collapses that into one chain:

// New: query returns a ready-to-use tester

TextFieldLocator emailFieldLocator = ui.findTextField().withLabel("Email");
emailFieldLocator.setValue("a@b.com");

// Or, equivalently, as a one-liner:
ui.findTextField().withLabel("Email").setValue("a@b.com");

findTextField() returns a TextFieldLocator, which is the test surface for TextField. No test() wrapping step. The tester methods that used to live on TextFieldTester are now method calls directly on the locator. Same for every other component: findButton(), findGrid(...), findCheckbox(), etc.

Similarly to Playwright, in case the locator is not specific, e.g. matches multiple components, it will throw a runtime exception in case a user interaction is simulated. You can specify the target component with additional rules or e.g. with the atIndex(int) method.

Components with parameterized testers (Grid, ComboBox, RadioButtonGroup) take a class witness once:

Person first = ui.findGrid(Person.class).getRow(0);
ui.findComboBox(User.class).select(user);

Filter chain reads like a sentence

The filter methods on a locator compose into a focused find:

ui.findButton().withText("Save").click();
ui.findButton().withAriaLabel("Reset form").click();
ui.findTextField().withLabel("Email").setValue("a@b.com");
ui.findButton().withClassName("primary").withTextContaining("Submit").click();

withLabel(...) is new in the locator API and worth a sentence: it resolves both the component's own label property and a separate <label for="..."> element (e.g. a NativeLabel you attached explicitly). That's the pattern you'd reach for in Playwright with getByLabel(...) — now it's first-class here too.

The escape hatch is one call away

Sometimes the tester surface doesn't cover what an assertion needs. component() gives you the underlying component instance:

TextField email = ui.findTextField().withLabel("Email").component();
Assertions.assertEquals("a@b.com", email.getValue());
Assertions.assertTrue(email.isInvalid());

This is the steam-valve for things the tester abstraction doesn't expose. Most tests won't need it for interaction, but it's there for the assertion side. The locator stays the primary surface; the component is the fallback.

Why the wrap step matters

It would be tempting to say "well, you can interact directly with the Component API — why bother with testers at all?" The reason testers exist (and now locators) is that direct Component-level interaction doesn't simulate what the user does.

Calling textField.setValue("x") server-side works. But it bypasses the "value came from the client" event flow, doesn't fire the right change listeners with isFromClient() == true, doesn't enforce that the field is enabled or visible, and won't catch a bug where the form silently ignores readonly fields. The tester (and now the locator) wraps these calls with the "as a user would" semantics. That's the win you used to opt into with test(...); in the new API you can't not get it.

So the rule of thumb: interact through the locator, assert through either.

A small end-to-end

Putting all of the above together — a realistic form-submission test:

@Test
void personForm_submit_recordsExpectedEntry() {
 try (var app = BrowserlessApplicationContext.create(routes)) {
 var ui = app.newUser().newWindow();
 var view = ui.navigate(PersonFormView.class);
 ui.findTextField().withLabel("Full name").setValue("Ada Lovelace");
 ui.findTextField().withLabel("Email").setValue("ada@example.com");
 ui.findButton().withCaption("Submit").click();

 // Assert against the component directly when that's the natural shape.
 Assertions.assertEquals(1, view.savedEntries().size());
 Assertions.assertEquals("Ada Lovelace",
 view.savedEntries().get(0).name());
 }
}

Compare against the older shape: no extends BrowserlessTest, no test(field).setValue(...) two-step, no per-test base-class configuration to manage cleanup.

Write your own locators - scale up with page objects

If your project has more than a handful of tests, consider applying the page object pattern to your browserless tests just like you would for TestBench or Playwright. The locator API was designed with this in mind: any custom Locator subclass IS a page object, and the existing tests in this repo already include one as a working template.

PersonFormLocator in the test sources is a tiny but representative example — it wraps a PersonForm composite and exposes intent-named methods instead of leaking field IDs into the test:

public class PersonFormLocator extends Locator<PersonForm, PersonFormLocator> {
 public PersonFormLocator() {
 super(PersonForm.class);
 }

 public PersonFormLocator fillIn(String name, String email) {
 new TextFieldLocator().withLabel("Name").inside(this).setValue(name);
 new TextFieldLocator().withLabel("Email").inside(this).setValue(email);
 return this;
 }

 public void submit() {
 new ButtonLocator().withText("Submit").inside(this).click();
 }
}

Two patterns to take away:

  • Subclass Locator<MyComposite, MyCompositeLocator> with the recursive self-type. Filter steps inherited from Locator (withLabel, withText, inside, etc.) stay chainable and keep returning your concrete locator type.
  • Compose built-in locators inside the page object and scope them with inside(this). Sub-queries only see descendants of the resolved composite, so two PersonForms on the same screen don't interfere.

Tests then read at a higher level:

ui.find(PersonFormLocator::new).fillIn("Ada", "ada@example.com");
ui.find(PersonFormLocator::new).submit();

Assertions.assertEquals("Submitted: Ada <ada@example.com>",
 ui.findSpan().withId("echo").getText());

Notice the test no longer mentions field IDs or the inner structure of the form — those are encapsulated in PersonFormLocator. When the form gets a new field, you change the page object once; every test keeps working.

The same pattern scales to whole views (LoginPageLocator, CheckoutFlowLocator), to recurring widgets across views (a custom date range picker, a hotel search bar), and to vendor components that ship as black-box Composites. For the simple "click this button" cases the built-in findButton() / findTextField() are enough — but once you find yourself repeating the same field navigation in three tests, lifting it into a custom locator is usually the right move.

A good rule of thumb: whenever you ship a custom component, ship a tiny locator next to it. Even if it starts as just a few intent-named methods over the built-in locators (fillIn, submit, selectRow), having it in place means every test that uses your component automatically benefits from the abstraction — and the locator grows naturally alongside the component as it gains features.

Why we think this is worth defaulting to

  • Composability over inheritance. Tests pick what they extend; the library only owns the try-with-resources.
  • One verb to interact. No "now wrap it" step between query and action. The locator is the action surface.
  • Multi-user is free. Same shape as single-user, just two ui variables. No thread-local plumbing to think about.
  • Familiar to Playwright users. Same mental model, mapped to Vaadin.
  • Better defaults. "Set this value" via a locator goes through the same path a real user click would. Direct component access stays available as an escape hatch — and is the right tool for assertions — but isn't the path of least resistance for interaction anymore.

Status and where to follow along

The existing API continues to work unchanged, so adopting the new shape is per-test, not project-wide. Naming is still settling in a few places, and the published docs trail the source a little — work-in- progress docs are visible here:

Some examples in those docs still use older names; the spirit is right, the surface is what's described above.

Feedback — especially on naming and on missing locator types for components you actually test against — is the most useful signal we can get right now. If you try the new API on a real test class, please open an issue or PR with what felt rough.

Matti Tahvonen
Matti Tahvonen
Matti Tahvonen has a long history in Vaadin R&D: developing the core framework from the dark ages of pure JS client side to the GWT era and creating number of official and unofficial Vaadin add-ons. His current responsibility is to keep you up to date with latest and greatest Vaadin related technologies. You can follow him on Twitter – @MattiTahvonen
Other posts by Matti Tahvonen