A fast, browser-free UI testing framework for Vaadin 25+ applications.
Browserless Test lets you write unit-style tests for your Vaadin views and components without launching a browser or a servlet container. Tests run entirely in-process against a mocked Vaadin environment, giving you millisecond-level execution times while still exercising real server-side component logic.
It complements Vaadin TestBench (browser-based end-to-end testing) by covering the fast-feedback layer of the testing pyramid.
- 70+ built-in component testers — ready-made wrappers for Grid, Button, TextField, ComboBox, Dialog, DatePicker, Upload, Charts, and many more
- View navigation — navigate to
@Route-annotated views with path, query, and template parameters - Component queries — find components by type from the current view or any parent layout
- Typed locators — fluent, compile-time-safe
findButton()/findTextField()entry points that combine query filters with tester actions - Keyboard shortcut simulation — fire shortcuts with modifier keys
- Signals / reactive state — process pending signal tasks in tests, including the confirmation of shared-signal writes
- Round-trip simulation — flush pending server-side changes
- Page reload simulation — simulate a browser refresh (F5): the UI is
recreated in the same Vaadin session, session-scoped state survives, and
@PreserveOnRefreshviews keep their instance and state - Focus and blur simulation — clicks and value changes made through testers
move focus like a real user would, firing focus and blur listeners in browser
order; server-side
Focusable.focus()andblur()calls are applied as well - Component tree debugging — print the UI tree on test failure with
TreeOnFailureExtension - Spring Boot integration —
SpringBrowserlessTestbase class with full Spring context support, including@WithMockUsersecurity testing - Quarkus integration —
QuarkusBrowserlessTestbase class with CDI injection and@TestSecuritysupport - Multi-user / multi-window testing — drive multiple users and multiple browser windows per user against a shared application within a single test; Vaadin thread-locals and per-user security context are switched automatically as you interact with each window
- Per-test Vaadin configuration — apply application properties, feature
flags, and
Lookupservices to a single test class or method with@BrowserlessTestConfig, without touching system properties or leaking into other tests - External navigation capture — assert URLs triggered by
Page.setLocation()andPage.open()(including_blank, named, and_self/_parent/_toptargets) without leaving the test - Custom testers — create your own
ComponentTesterimplementations and register them with@Tests
| Module | Artifact ID | Description |
|---|---|---|
| shared | browserless-test-shared |
Core framework: mocked Vaadin environment, component testers, navigation, queries |
| junit6 | browserless-test-junit6 |
JUnit 6 integration: base classes and extensions |
| spring | browserless-test-spring |
Spring / Spring Boot integration |
| quarkus | browserless-test-quarkus |
Quarkus integration |
| bom | browserless-test-bom |
Bill of Materials for dependency management |
- Java 21+
- Vaadin 25.1+
- Maven (the framework is distributed as Maven artifacts)
Add the BOM to your <dependencyManagement> section:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>browserless-test-bom</artifactId>
<version>${browserless-test.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>Spring Boot:
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>browserless-test-spring</artifactId>
<scope>test</scope>
</dependency>Quarkus:
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>browserless-test-quarkus</artifactId>
<scope>test</scope>
</dependency>Plain JUnit 6:
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>browserless-test-junit6</artifactId>
<scope>test</scope>
</dependency>import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.ContextConfiguration;
import org.junit.jupiter.api.Test;
@ContextConfiguration(classes = TestConfig.class)
@ViewPackages(classes = AdminView.class)
class AdminViewTest extends SpringBrowserlessTest {
@Test
@WithMockUser(roles = "ADMIN")
void adminCanAccessView() {
AdminView view = navigate(AdminView.class);
assertNotNull(view);
}
}import com.vaadin.browserless.quarkus.QuarkusBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import org.junit.jupiter.api.Test;
@QuarkusTest
@ViewPackages(classes = MainView.class)
class MainViewTest extends QuarkusBrowserlessTest {
@Test
@TestSecurity(user = "admin", roles = "ADMIN")
void accessProtectedView() {
MainView view = navigate(MainView.class);
assertNotNull(view);
}
}import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.flow.component.button.Button;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@ViewPackages(classes = CartView.class)
class CartViewTest extends BrowserlessTest {
@Test
void addItemToCart() {
CartView view = navigate(CartView.class);
// interact with components through testers
test(view.getAddButton()).click();
// find components and verify state
Span cartCount = find(Span.class).withId("cart-count").single();
assertEquals("1", cartCount.getText());
}
@Test
void queryComponents() {
navigate(CartView.class);
// find components by type
Button btn = find(Button.class).first();
assertNotNull(btn);
}
}The Locators API is a typed, fluent layer over find(Class) /
ComponentQuery. Every built-in component tester has a matching findXxx()
entry point that returns a locator — an object that exposes both the query
filters and that component's tester actions, so you can find and act on a
component in a single chain. Resolution is deferred to the first action and
cached, so a locator can be reused (for example across a roundTrip()).
window.findTextField().withId("name").setValue("World");
window.findButton().withText("Save").click();
assertEquals("Saved: World", window.findSpan().withId("echo").getText());The typed findXxx() / use(...) entry points are available out of the box on
every BrowserlessUIContext window (app.newUser().newWindow()), as used in
the examples here.
The single-user BrowserlessTest base class exposes the lower-level
find(Class) query API. To get the same typed locators in a plain
BrowserlessTest, have your test (or a shared base class) implement
com.vaadin.browserless.locator.Locators:
import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.locator.Locators;
class CartViewTest extends BrowserlessTest implements Locators {
@Test
void addItemToCart() {
navigate(CartView.class);
findButton().withText("Add to cart").click();
assertEquals("1", findSpan().withId("cart-count").getText());
}
}The JUnit 5 extensions (BrowserlessExtension / BrowserlessClassExtension)
also expose the typed findXxx() locator API directly, so you get it for free
when you register the extension explicitly instead of extending the base class:
@ViewPackages(classes = CartView.class)
class CartViewTest {
@RegisterExtension
BrowserlessExtension ext = new BrowserlessExtension();
@Test
void addItemToCart() {
ext.navigate(CartView.class);
ext.findTextField().withId("quantity").setValue("3");
ext.findButton().withId("add").click();
assertEquals("3", ext.findSpan().withId("cart-count").getText());
}
}Use BrowserlessClassExtension instead for a shared Vaadin environment
across all tests in the class. Tests that depend on commercial Vaadin
components (Charts, etc.) can mix in CommercialLocators on their own
subclass to get the additional find<Component>() entries.
Locators carry the common filters directly: withId, withTestId,
withClassName / withoutClassName, withAttribute (with or without an
expected value), withoutAttribute, withinSlot for content a component
places in a named slot, and withCondition for an arbitrary typed predicate.
Filters that depend on a component capability are mixed in only where the component actually supports them, so misuse is a compile error rather than a runtime surprise:
| Filter | Available when the component is |
|---|---|
withText / withTextContaining |
HasText |
withLabel / withLabelContaining |
HasLabel |
withAriaLabel / withAriaLabelContaining |
HasAriaLabel |
withValue |
HasValue (typed to its value) |
withTheme / withoutTheme |
HasTheme |
// Button is HasText — compiles
window.findButton().withText("Save").click();
// TextField is HasLabel + HasValue, but not HasText
window.findTextField().withLabel("Name").setValue("Ada");
window.findTextField().withValue("Ada"); // value type is checked: String here
// window.findTextField().withText("Name"); // does NOT compileFor filters not surfaced on the locator (for example withPropertyValue or
withResultsSize), use the with(q -> ...) escape hatch to reach the
underlying ComponentQuery:
window.findButton().with(q -> q.withPropertyValue(Button::getText, "Save"))
.click();When a filter chain matches more than one component, pick one with atIndex(n)
(1-based). Scope the search to a subtree with inside(component) or
inside(otherLocator) — the latter resolves its parent lazily, at the moment
the child is resolved:
// pick the second button in the view
window.findButton().atIndex(2).click();
// only look inside a resolved parent
window.findButton().inside(window.findButton().withId("toolbar")).click();Content a component places in a named slot — a card's footer, a dialog's
header — is scoped with withinSlot(name). It matches content nested inside
the slot too, not only the component that is the slot root:
// the Save button in the card's footer, however deeply it is nested there
window.findButton().inside(card).withinSlot("footer").withText("Save").click();Slot names are the ones the component uses in the browser and differ per
component: a card's header slot is header, a dialog's is header-content.
A name nothing is slotted under simply matches no components.
When slots nest, the outermost one wins, so everything below a slot matches:
a button in the header of a card that another card placed in its footer is
footer content, because that footer is the outer slot. Only the search context
bounds the walk, so scoping to the slot's host, to a layout above it, or to
nothing at all all see the same slots. The withinSlot javadoc walks through
annotated component trees for that case and for components that slot content
into wrapper elements.
Beyond the action methods, locators expose component() (the single match,
cached), components() (all matches), exists() (true if anything matches),
and invalidate() (drop the cached resolution and the atIndex pick so the
next action re-resolves — useful after a UI change replaces the component).
When the test already holds a component reference, use(component) seeds a
locator with it directly instead of running a query:
window.use(form.nameField).setValue("Ada");
window.use(form.submit).click();For composite components, subclass Locator<C, SELF> and compose the built-in
locators, scoping them to the composite's subtree with inside(this):
import com.vaadin.browserless.locator.Locator;
import com.vaadin.flow.component.button.ButtonLocator;
import com.vaadin.flow.component.textfield.TextFieldLocator;
public class PersonFormLocator extends Locator<PersonForm, PersonFormLocator> {
public PersonFormLocator() {
super(PersonForm.class);
}
public PersonFormLocator fillIn(String name, String email) {
new TextFieldLocator().withId("pf-name").inside(this).setValue(name);
new TextFieldLocator().withId("pf-email").inside(this).setValue(email);
return this;
}
public void submit() {
new ButtonLocator().withId("pf-submit").inside(this).click();
}
}Invoke a custom locator through the generic find(Supplier) entry point:
window.find(PersonFormLocator::new).fillIn("Ada", "ada@example.com").submit();Locators are the typed convenience layer; find(Class) and ComponentQuery
remain available for ad-hoc, lower-level queries and for filters not surfaced
on locators. Use whichever fits — they search the same component tree.
find(Class), findInView(Class) and the typed locators all walk the same
thing: the server-side component tree. A component that another component
renders per item is rendered into that component and not into the tree, and
the content of an overlay is attached only while the overlay is open. Neither
is reachable that way — reach it through that component's tester instead. The
lookup returns an empty result rather than an error, so the failure reads as
"the component was never created".
grid.addComponentColumn(person -> new Checkbox(person.isSubscriber()))
.setKey("subscriber");A grid renders that checkbox into the column, not into the grid, so
find(Checkbox.class) finds none of them, no matter how many rows are on
screen. GridTester hands out the one the grid rendered:
var checkbox = (Checkbox) test(grid).getCellComponent(0, "subscriber");
test(checkbox).click();getCellComponent(int row, int column)/getCellComponent(int row, String columnKey)— the component the grid rendered for the cell, which is the one the browser shows. Reading the same cell twice gives the same instance, and the instance is replaced when the row is rendered anew, for example afterrefreshItem(...). A row the client has not asked for yet is scrolled into view first, the way a user reaches it. A cell the grid does not render at all, such as one in a hidden column, throws.renderCellComponent(int row, int column)/renderCellComponent(int row, String columnKey)— renders the cell on its own, without the grid, and attaches the copy to the grid so that it can be used. Every call renders the cell again and leaves the copy behind, so a laterfind()reports every one of them. It is for the cells the grid does not render, and for tests written against the old behaviour ofgetCellComponent.getCellText(int row, int column)— the text the cell sends to the client, for both value and component renderers.getLitRendererPropertyValue(...)/invokeLitRendererFunction(...)— forLitRenderercolumns, which have no server-side component at all.
A context menu's content is not attached to the UI until a client opens the
overlay, so a top-level find() does not see it:
find(Div.class).withText("Rename").all(); // empty while the menu is closed
test(menu).open();
find(Div.class).withText("Rename").all(); // one matchA closed menu is not attached to the UI, so, as in the browser, its items
cannot be interacted with: clickItem("Rename"), isItemChecked(...) and
getItemTooltipText(...) throw an IllegalStateException until the menu is
opened. The tester-scoped test(menu).find(Div.class) is the exception, since
it reads the menu contents rather than the UI; it finds the items whether the
menu is open or not, and returns them detached while it is closed.
A GridContextMenu is always about a row, so its tester takes one:
test(grid).contextMenu(row) targets a row without opening the menu, open()
then opens it there, and clickItem("Rename") clicks an item of the open menu.
GridContextMenuTester.open(row) opens the menu on a row directly.
Some tests need a Vaadin environment configured differently from the rest of
the suite — a view behind a feature flag, or a setting such as
devmode.sessionSerialization.enabled. Annotate the test class or the test
method with @BrowserlessTestConfig:
@ViewPackages(classes = CartView.class)
@BrowserlessTestConfig(
applicationProperties = "devmode.sessionSerialization.enabled=true",
featureFlags = "myExperimentalFeature")
class CartViewTest extends BrowserlessTest {
@Test
void experimentalFeatureIsAvailable() {
// the feature flag is enabled for this test only
}
@Test
@BrowserlessTestConfig(featureFlags = "myExperimentalFeature=false")
void fallbackWhenFeatureIsDisabled() {
// method level configuration wins over the class level one
}
}-
applicationPropertiesentries arename=valuepairs, applied to the Vaadin deployment configuration of the mock environment. The value is everything after the first=. -
featureFlagsentries are either a feature identifier, to enable it, or anid=true|falsepair. They override whatevervaadin-featureflags.propertiesor thevaadin.experimental.*system properties declare. -
lookupServicesare implementation classes registered with the VaadinLookup— anInstantiatorFactory, aResourceProvider, and so on:@BrowserlessTestConfig(lookupServices = MyInstantiatorFactory.class) class MyViewTest extends BrowserlessTest { }
All of them are scoped to the Vaadin environment created for the test, so there is nothing to reset afterwards and nothing leaks into other tests.
Every annotation a test inherits is merged in, rather than shadowed by the
nearest one. The closer a declaration is to the test method, the higher it
ranks: method, then test class, then superclasses from the nearest up, then —
for a @Nested test — enclosing classes from the innermost out. So a shared
abstract base test can declare part of the configuration and a subclass refines
it:
@BrowserlessTestConfig(applicationProperties = "base.property=fromBase")
abstract class AbstractViewTest extends BrowserlessTest {
}
@BrowserlessTestConfig(featureFlags = "myExperimentalFeature")
class CartViewTest extends AbstractViewTest {
// both base.property and myExperimentalFeature apply
}Lookup services are the exception: they accumulate instead of being
replaced, so a test method can add a service but cannot remove one declared by
its class. Services required by the Spring and Quarkus integrations are always
registered and are never affected by the test configuration — since 25.3 they
come from frameworkLookupServices(), so an override of the deprecated
lookupServices() adds to them and can no longer replace one.
A method level annotation cannot be honored when the Vaadin environment is
shared by all the tests in a class (BrowserlessClassExtension), and is
rejected with an error.
The same configuration can be defined programmatically, without annotations, on the extensions:
@RegisterExtension
BrowserlessExtension extension = new BrowserlessExtension()
.withApplicationProperty("devmode.sessionSerialization.enabled", "true")
.withFeatureFlags(FeatureFlags.COLLABORATION_ENGINE_BACKEND);on the application context builder, for multi-user tests:
try (var app = BrowserlessApplicationContext.create(builder -> builder
.withViewPackages(CartView.class)
.withApplicationProperty("devmode.sessionSerialization.enabled", "true")
.withFeatureFlags("myExperimentalFeature"))) {
// ...
}or by overriding testConfiguration() on a base class based test:
@Override
protected BrowserlessConfiguration testConfiguration() {
return BrowserlessConfiguration.builder()
.withConfiguration(super.testConfiguration())
.withFeatureFlags("myExperimentalFeature").build();
}When both are used, a configuration defined on an extension or on the application context builder wins over the class level annotation, and loses against the method level one.
A testConfiguration() override behaves differently: super.testConfiguration()
returns the configuration already resolved from the annotations, so whatever the
override adds on top of it wins over all of them, the method level one
included. Build on super.testConfiguration() to refine the declared
configuration, and leave out the values a test method should be able to
override.
Note
With Spring, a Vaadin property defined in the Spring environment (e.g.
vaadin.devmode.sessionSerialization.enabled in application.properties) is
applied by SpringServlet on top of the test configuration, and therefore
wins over @BrowserlessTestConfig. Use @TestPropertySource to override
such a property for a test. Properties that are not Vaadin init parameters
are not affected.
Signal effects and shared-signal confirmations are not executed on a background
thread pool in a browserless test. They are queued, so that a test can decide
when they run, and are executed on the test thread by
runPendingSignalsTasks() (also available as
window.runPendingSignalsTasks() in the multi-window API and on the JUnit
extension).
CompletableFuture.runAsync(() -> counterSignal.incrementBy(10.0));
runPendingSignalsTasks(); // waits up to 100 ms for the first task
assertEquals("Counter: 10", test(view.counter).getText());A write to a SharedValueSignal, SharedListSignal,
SharedMapSignal or SharedNumberSignal is applied optimistically and
is visible through peek() straight away. The SignalOperation
returned by the write is completed only when the underlying signal tree
confirms the command, and that confirmation is dispatched through the same
queue — so it completes on the next runPendingSignalsTasks():
var operation = tickets.insertLast("a ticket");
assertEquals(1, tickets.peek().size()); // already applied
assertFalse(operation.result().isDone()); // not confirmed yet
runPendingSignalsTasks();
assertTrue(operation.result().join().successful());Blocking on the operation before draining the queue —
operation.result().get(5, SECONDS) — always times out: the confirmation
task can only run on the thread that is blocked waiting for it. A timeout there
means the queue has not been drained, not that the write was lost.
Focus is tracked per UI while a test runs, so focus and blur listeners fire implicitly when a tester clicks a component or sets its value, the way they do with a real user in a browser:
test(amount).setValue("100"); // focuses the field
test(save).click(); // blurs the field first, then handles the clickThe blur listener of amount runs before the click listener of save, in the
same order as in a browser. The events are fired as DOM events coming from the
client, so isFromClient() returns true for them, exactly as after a real
round-trip.
Focus can also be moved explicitly, which is useful when nothing else is interacted with afterwards:
test(amount).focus();
test(amount).blur();
assertFalse(test(amount).isFocused());Server-side Focusable.focus() and Focusable.blur() calls are simulated as
well, for example a click listener that opens a dialog and focuses a field in
it. Such a call only schedules client-side JavaScript, which is applied at the
end of a focus-tracked interaction, on roundTrip(), and whenever focus is
queried with isFocused(). The resulting focus or blur event reports
isFromClient() == false, just like with a browser:
Button open = new Button("Open", e -> {
dialog.open();
quickAdd.focus();
});
test(open).click();
assertTrue(test(quickAdd).isFocused());- Focus moves on clicks made through the common tester
click()implementation, on value changes made with a tester'ssetValue(...), and on explicitfocus()/blur()calls. - Some testers fire their events directly instead of going through that
implementation — among them radio button, menu bar and context menu item
clicks — so they neither take focus nor blur the previously focused
component. The same holds for events fired by hand, for example with
ComponentUtil.fireEvent(...). A server-sideFocusablecall scheduled by such an interaction stays queued until the next focus-tracked interaction, aroundTrip(), or anisFocused()query. - Only
Focusablecomponents take focus. Interacting with anything else blurs the previously focused component and leaves nothing focused, as focus falls back to the document body in a browser. focus()andblur()throw anIllegalStateExceptionfor a component that is disabled or not attached to a UI, since neither can take or lose focus in a browser either. A read-only field can be focused.isFocused()never throws and reportsfalsefor a component that is not attached.- Detecting server-side
Focusablecalls relies on matching the JavaScript that Flow generates for them, and reading the pending JavaScript queue consumes it: when a focus or blur call is pending, other JavaScript queued at the same time is dropped. Everything the simulation takes off the queue is listed in a debug log undercom.vaadin.browserless.FocusTracker. Handling the queue centrally is tracked in #221.
For tests that need to drive multiple users — or multiple browser windows for the same user — against a single application, Browserless Test exposes a layered context API that mirrors the Vaadin hierarchy:
| Context | Maps to | Created via |
|---|---|---|
BrowserlessApplicationContext<C> |
shared VaadinServletService |
BrowserlessApplicationContext.create(viewPackagesOrClasses) (or a framework factory) |
BrowserlessUserContext |
one VaadinSession (one user) |
app.newUser() / app.newUser(credentials) / app.newUser(username, roles...) |
BrowserlessUIContext |
one UI (one browser window) |
user.newWindow() |
BrowserlessUIContext exposes the same DSL as BrowserlessTest (navigate,
find, findInView, test, roundTrip, reload). Every DSL call
automatically activates the context: Vaadin thread-locals (VaadinService,
VaadinSession, UI, VaadinRequest, VaadinResponse) are switched to the
target window, and on a user-switch the outgoing user's security context is
saved and the incoming user's snapshot is restored. You can interleave
operations on different windows freely without manual context switching.
The application context is AutoCloseable: closing it (typically via
try-with-resources) closes every user and every window in the right order,
fires destroy listeners, and clears Vaadin and security thread-locals.
import com.vaadin.browserless.BrowserlessApplicationContext;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Paragraph;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SharedCounterTest {
@Test
void twoUsersShareApplicationState() {
try (var app = BrowserlessApplicationContext
.create(SharedCounterView.class)) {
var w1 = app.newUser().newWindow();
var w2 = app.newUser().newWindow();
w1.navigate(SharedCounterView.class);
w2.navigate(SharedCounterView.class);
// user 1 increments — only their UI reflects it locally
w1.test(w1.find(Button.class).withText("Increment").single()).click();
assertEquals("Count: 1", w1.find(Paragraph.class).single().getText());
assertEquals("Count: 0", w2.find(Paragraph.class).single().getText());
// user 2 refreshes to observe the shared application state
w2.test(w2.find(Button.class).withText("Refresh").single()).click();
assertEquals("Count: 1", w2.find(Paragraph.class).single().getText());
}
}
}SpringBrowserlessApplicationContext.create(springCtx, viewPackagesOrClasses)
wires the application context to the Spring ApplicationContext and (when
Spring Security is on the classpath) installs a SecurityContextHandler so
per-user authentication is automatically isolated across windows. The
newUser(username, roles...) shorthand mirrors @WithMockUser.
import com.testapp.security.LoginView;
import com.testapp.security.ProtectedView;
import com.vaadin.browserless.SecuredBrowserlessApplicationContext;
import com.vaadin.browserless.SpringBrowserlessApplicationContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.security.core.Authentication;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = SecurityTestConfig.class)
class MultiUserSecurityTest {
@Autowired
private ApplicationContext springCtx;
@Test
void securityContextIsIsolatedPerUser() {
try (SecuredBrowserlessApplicationContext<Authentication> app =
SpringBrowserlessApplicationContext.createSecured(
springCtx, "com.testapp.security")) {
var adminWindow = app.newUser("john", "USER").newWindow();
var anonWindow = app.newUser().newWindow();
adminWindow.navigate(ProtectedView.class);
assertInstanceOf(ProtectedView.class, adminWindow.getCurrentView());
// Anonymous user is redirected to the login view
assertThrows(IllegalArgumentException.class,
() -> anonWindow.navigate(ProtectedView.class));
assertInstanceOf(LoginView.class, anonWindow.getCurrentView());
// Switch back — admin's SecurityContext is restored automatically
adminWindow.navigate(ProtectedView.class);
assertInstanceOf(ProtectedView.class, adminWindow.getCurrentView());
}
}
}For full control over the principal, app.newUser(authentication) accepts a
hand-built Authentication token.
QuarkusBrowserlessApplicationContext.create(viewPackagesOrClasses) resolves
Quarkus beans through CDI and installs a SecurityContextHandler backed by
CurrentIdentityAssociation. The newUser(username, roles...) shorthand
builds a matching QuarkusSecurityIdentity.
import com.testapp.security.LoginView;
import com.testapp.security.ProtectedView;
import com.vaadin.browserless.SecuredBrowserlessApplicationContext;
import com.vaadin.browserless.quarkus.QuarkusBrowserlessApplicationContext;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
class MultiUserSecurityTest {
@Test
void securityContextIsIsolatedPerUser() {
try (SecuredBrowserlessApplicationContext<SecurityIdentity> app =
QuarkusBrowserlessApplicationContext
.createSecured("com.testapp.security")) {
var adminWindow = app.newUser("john", "USER").newWindow();
var anonWindow = app.newUser().newWindow();
adminWindow.navigate(ProtectedView.class);
assertInstanceOf(ProtectedView.class, adminWindow.getCurrentView());
assertThrows(IllegalArgumentException.class,
() -> anonWindow.navigate(ProtectedView.class));
assertInstanceOf(LoginView.class, anonWindow.getCurrentView());
adminWindow.navigate(ProtectedView.class);
assertInstanceOf(ProtectedView.class, adminWindow.getCurrentView());
}
}
}For a hand-built identity, pass it directly:
app.newUser(QuarkusSecurityIdentity.builder()...build()).
reload() simulates the user pressing F5 on one window: that window's UI is
detached and a fresh one is created in the same VaadinSession, and the
current location — route parameters and query string included — is rendered
again. Session-scoped state survives and sibling windows are untouched.
var w = app.newUser().newWindow();
var cart = w.navigate(CartView.class); // @PreserveOnRefresh
w.test(w.find(Button.class).withId("add").single()).click();
// Same instance and state: @PreserveOnRefresh survives the refresh
assertSame(cart, w.reload(CartView.class));A view without @PreserveOnRefresh is recreated, so its state resets — the
same distinction a real browser refresh makes. The no-argument reload()
returns the resulting view as a HasElement; reload(Class) additionally
asserts the expected view type. Both are also available on BrowserlessTest
and on BrowserlessExtension.
When a view triggers Page.setLocation() or Page.open(), the URL is
captured on the window's mock Page and can be asserted directly:
var w = app.newUser().newWindow();
w.navigate(CheckoutView.class);
// Page.setLocation("https://vaadin.com/") — _self navigation
w.test(w.find(Button.class).withText("Go to Vaadin").single()).click();
assertEquals("https://vaadin.com/", w.getExternalNavigationURL());
// Page.open("https://payment.example.com/checkout?id=123") — _blank
w.test(w.find(Button.class).withText("Pay").single()).click();
assertEquals("https://payment.example.com/checkout?id=123",
w.getExternalNavigationURL("_blank"));
// All windows opened by name (excluding _self / _parent / _top navigations)
Map<String, List<String>> opened = w.getOpenedWindows();getExternalNavigationURL() (no argument) covers same-window navigations
(_self, _parent, _top, empty, or null);
getExternalNavigationURL(name) and getOpenedWindows() cover named windows
and _blank.
app.newUser(username, roles...)requires the application context to be configured with aSecurityContextHandler; the Spring and Quarkus factories install one by default.- The per-user security snapshot is captured at user-switch time (not on every activate), so mutations made while the user is active persist on the thread until you switch to a different user — at which point the live state is captured into that user's snapshot and restored on every subsequent activation.
- Same-user window switches don't touch the snapshot, so per-window UI state is preserved across interleaved operations within one user.
See CONTRIBUTING.md for how to build and test the project and
what a pull request is expected to look like, CONVENTIONS.md
for the canonical list of conventions, and
guidelines/ for the reasoning behind them — including
how a tester simulates the browser and the shared test contracts
(ClearContract, ClearButtonContract, CommitsEmptyValueContract) that
every value tester's test class implements.
This project is licensed under the Apache License, Version 2.0.