From 93d6616aeb051d6b86f1014b0cbea5c7db139eab Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Fri, 18 Sep 2026 09:11:25 +0000 Subject: [PATCH 1/2] Document how the browserless test environment differs from a running application Adds an Environment Differences page covering the browserless test lifecycle, why session-scoped beans cannot be injected into a test class field, and why authentication established during a test needs a second navigation. Also documents that a location string passed to `navigate()` can carry a query string and a fragment, and adds a note to the Spring Security page about signing in after the environment is created. Related to vaadin/browserless-test#201 --- .../browserless/environment-differences.adoc | 116 ++++++++++++++++++ .../testing/browserless/getting-started.adoc | 11 ++ .../testing/browserless/spring-security.adoc | 4 + 3 files changed, 131 insertions(+) create mode 100644 articles/flow/testing/browserless/environment-differences.adoc diff --git a/articles/flow/testing/browserless/environment-differences.adoc b/articles/flow/testing/browserless/environment-differences.adoc new file mode 100644 index 0000000000..bc5fcc1a4a --- /dev/null +++ b/articles/flow/testing/browserless/environment-differences.adoc @@ -0,0 +1,116 @@ +--- +title: Environment Differences +page-title: How the Vaadin browserless test environment differs +description: What the browserless environment creates, in which order, and which application behavior therefore needs a different approach in a test. +meta-description: Learn the browserless test lifecycle in Vaadin, and how it affects session-scoped beans and authentication applied during a test. +order: 12 +--- + + += How the Browserless Environment Differs + +A browserless test runs your application's server-side code against a mocked Vaadin environment created inside the JUnit test. Views, components, and services behave as they do in a running application, but the environment is built and torn down around each test method rather than by a servlet container at startup. + +That ordering is observable. Two application patterns depend on it, and in both the test fails in a way that looks like an application defect: a bean that can't be resolved, and a view that doesn't match the authenticated user. + + +[#test-lifecycle] +== Test Lifecycle + +For a [classname]`SpringBrowserlessTest`, each test method runs through these steps: + +. JUnit creates the test instance, and Spring injects its [annotationname]`@Autowired` fields. No Vaadin environment exists yet -- there's no [classname]`VaadinService`, no [classname]`VaadinSession`, and no [classname]`UI`. +. JUnit extension callbacks run. [classname]`SpringExtension` populates the Spring [classname]`SecurityContextHolder` from [annotationname]`@WithMockUser`, [annotationname]`@WithAnonymousUser`, or [annotationname]`@WithUserDetails`. +. The browserless environment is created: [classname]`VaadinService`, [classname]`VaadinSession`, and [classname]`UI`, followed by navigation to the root route. The authentication from the previous step is already in place, so view access control sees the simulated user. +. The test method body runs. +. The environment is torn down, and the session is closed. + +[classname]`BrowserlessTest` and [classname]`QuarkusBrowserlessTest` follow the same sequence without the Spring injection step. + +The consequence is that the Vaadin session exists only inside the test method. Anything that needs a session -- including anything Vaadin resolves per session -- has to be reached from there, not from a field of the test class. + + +[#session-scoped-beans] +== Session-Scoped Beans + +A [annotationname]`@VaadinSessionScope` or [annotationname]`@SessionScope` bean can't be injected into a test class field. The field is injected in step 1, when no session exists, so the bean can't be resolved: + +.Doesn't Work +[source,java] +---- +@SpringBootTest +class CartViewTest extends SpringBrowserlessTest { + + @Autowired + private Cart cart; // No session exists when this field is injected. +} +---- + +Because this is an instance creation failure, it fails every test in the class, not only the ones that use the bean. + +Resolve the bean inside the test method instead, where the session is available: + +.Works +[source,java] +---- +@SpringBootTest +class CartViewTest extends SpringBrowserlessTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void addItem_cartContainsItem() { + CartView view = navigate(CartView.class); + Cart cart = applicationContext.getBean(Cart.class); + + test(view.addButton).click(); + + Assertions.assertEquals(1, cart.getItems().size()); + } +} +---- + +Injecting [classname]`ObjectProvider` or annotating the field with [annotationname]`@Lazy` works as well, because both defer resolution until the bean is first used. + +Views and other components aren't affected: they're instantiated during navigation, inside the test method, so their own session-scoped dependencies resolve normally. + + +[#authentication-applied-during-a-test] +== Authentication Applied During a Test + +Spring Security test annotations are applied in step 2, before the environment is created, which is why a simulated logged-in user isn't redirected to the login view. See <> for the full setup. + +Authentication established later -- from the test method body, or with `setupBefore = TestExecutionEvent.TEST_EXECUTION` -- arrives after the initial navigation has already happened. Subsequent requests use the new authentication, but the view rendered during setup stays in place, so [methodname]`getCurrentView()` still returns the result of the earlier, anonymous navigation. + +Navigate again after signing in: + +[source,java] +---- +@Test +@WithMockUser(username = "admin", roles = "ADMIN", + setupBefore = TestExecutionEvent.TEST_EXECUTION) +void adminSignsIn_adminViewShown() { + // The initial navigation ran while the user was still anonymous. + Assertions.assertInstanceOf(LoginView.class, getCurrentView()); + + // Navigating again applies access control to the current authentication. + navigate(AdminView.class); + + Assertions.assertTrue(find(Avatar.class).single().isVisible()); +} +---- + +.Reloading Isn't Enough +[NOTE] +[methodname]`reload()` isn't a substitute for navigating again. It re-renders the current location, and when access control has redirected to the login view, that location is the login view. + +Where the scenario under test allows it, prefer applying the authentication before the test method, with a plain [annotationname]`@WithMockUser` or [annotationname]`@WithUserDetails`. The initial navigation then reflects the authenticated user, and no second navigation is needed. + + +== Components That Don't Exist Yet + +Component queries walk the server-side component tree, which holds only what has been created. A component rendered per item exists once something renders it, and the contents of an overlay are attached only while the overlay is open. Until then, a query returns an empty result rather than an error. See <> for how to reach them. + + +[discussion-id]`4B6C9E13-7A85-42D0-9F3B-1C8E5D4A2B76` diff --git a/articles/flow/testing/browserless/getting-started.adoc b/articles/flow/testing/browserless/getting-started.adoc index 33304caeab..43bd13daed 100644 --- a/articles/flow/testing/browserless/getting-started.adoc +++ b/articles/flow/testing/browserless/getting-started.adoc @@ -202,6 +202,17 @@ To navigate to another registered view, use the [methodname]`navigate()` methods All navigation methods return the instantiated view, so that its fields can be used directly for testing. +A location string can also [since:com.vaadin:vaadin@V25.3]#carry a query string, a fragment, or both#. They are split off from the path the same way the browser address bar does it, so the query parameters reach the view through the navigation event: + +[source,java] +---- +navigate("orders/1?tab=history&page=2", OrderView.class); +---- + +Query parameters can be passed as a separate [classname]`QueryParameters` object through [methodname]`UI.getCurrent().navigate(String, QueryParameters)`. Providing them both ways at once is rejected, because the query string in the location would be dropped. + +A location consisting only of a fragment, such as `"#details"`, identifies a place within the current page rather than a route, so it leaves the current view in place. + [NOTE] Navigation by location string takes in the view class, so that the initialized view can be automatically validated to be the expected one. diff --git a/articles/flow/testing/browserless/spring-security.adoc b/articles/flow/testing/browserless/spring-security.adoc index 58dfbd9a9e..a69fa47ed2 100644 --- a/articles/flow/testing/browserless/spring-security.adoc +++ b/articles/flow/testing/browserless/spring-security.adoc @@ -55,6 +55,10 @@ class TestViewSecurityConfig { With this support, you can use Spring Security test annotations -- such as [annotationname]`@WithMockUser`, [annotationname]`@WithAnonymousUser`, or [annotationname]`@WithUserDetails` -- to simulate different authentication scenarios with test method granularity. More information is available on the https://docs.spring.io/spring-security/reference/servlet/test/method.html#test-method-withmockuser[Spring Security documentation] site. Authentication details are available before creating the UI instance and navigating to the default route. This way redirects to the login view aren't performed when simulating logged-in users. In the same way, custom redirect logic for authenticated users works as expected. +.Signing In During a Test +[CAUTION] +This holds for authentication that is in place before the test method starts. A sign-in performed later -- from the test method body, or with `setupBefore = TestExecutionEvent.TEST_EXECUTION` -- happens after the initial navigation, so [methodname]`getCurrentView()` still returns the view rendered for the previous user. Navigate again to apply access control to the new authentication. See <>. + To use Spring Security test annotations, first make sure the dependency is added to the project. [source,xml] From f85d123bae8d8ded56b1307cee322bda6da8556b Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Fri, 18 Sep 2026 10:57:10 +0000 Subject: [PATCH 2/2] Address review feedback on the browserless environment differences page Move the page to the end of the browserless section, since it is an advanced topic rather than something to read right after Getting Started. Lead the authentication section with the approach that works without a second navigation, and only then describe a sign-in performed during the test. Make the example's comment say what the setup navigation did, since the code around it never mentions one. Say that Page.reload() recreating the UI at the active location is what a browser reload does, not a limitation. Suggest ObjectProvider as the default way to reach a session scoped bean, and keep @Lazy and ApplicationContext.getBean as alternatives. In Getting Started, fold navigation with a query string into the list of navigate() forms, and drop the UI.getCurrent().navigate(String, QueryParameters) variant: it bypasses the target view validation the navigate() methods perform, and the location string already carries the query parameters. --- .../browserless/environment-differences.adoc | 36 ++++++++++++------- .../testing/browserless/getting-started.adoc | 16 +++------ 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/articles/flow/testing/browserless/environment-differences.adoc b/articles/flow/testing/browserless/environment-differences.adoc index bc5fcc1a4a..6760543647 100644 --- a/articles/flow/testing/browserless/environment-differences.adoc +++ b/articles/flow/testing/browserless/environment-differences.adoc @@ -3,7 +3,7 @@ title: Environment Differences page-title: How the Vaadin browserless test environment differs description: What the browserless environment creates, in which order, and which application behavior therefore needs a different approach in a test. meta-description: Learn the browserless test lifecycle in Vaadin, and how it affects session-scoped beans and authentication applied during a test. -order: 12 +order: 80 --- @@ -48,7 +48,7 @@ class CartViewTest extends SpringBrowserlessTest { Because this is an instance creation failure, it fails every test in the class, not only the ones that use the bean. -Resolve the bean inside the test method instead, where the session is available: +Inject an [classname]`ObjectProvider` instead, and ask it for the bean inside the test method, where the session is available: .Works [source,java] @@ -57,12 +57,12 @@ Resolve the bean inside the test method instead, where the session is available: class CartViewTest extends SpringBrowserlessTest { @Autowired - private ApplicationContext applicationContext; + private ObjectProvider cartProvider; @Test void addItem_cartContainsItem() { CartView view = navigate(CartView.class); - Cart cart = applicationContext.getBean(Cart.class); + Cart cart = cartProvider.getObject(); test(view.addButton).click(); @@ -71,7 +71,7 @@ class CartViewTest extends SpringBrowserlessTest { } ---- -Injecting [classname]`ObjectProvider` or annotating the field with [annotationname]`@Lazy` works as well, because both defer resolution until the bean is first used. +Annotating the [classname]`Cart` field with [annotationname]`@Lazy` works as well, and so does injecting [classname]`ApplicationContext` and calling [methodname]`getBean(Cart.class)` from the test method. All three defer the lookup to the moment the bean is first used. Views and other components aren't affected: they're instantiated during navigation, inside the test method, so their own session-scoped dependencies resolve normally. @@ -79,9 +79,22 @@ Views and other components aren't affected: they're instantiated during navigati [#authentication-applied-during-a-test] == Authentication Applied During a Test -Spring Security test annotations are applied in step 2, before the environment is created, which is why a simulated logged-in user isn't redirected to the login view. See <> for the full setup. +Apply the authentication before the test method starts, with a plain [annotationname]`@WithMockUser`, [annotationname]`@WithAnonymousUser`, or [annotationname]`@WithUserDetails` on the test class or method. It's then in place in step 2, before the environment is created, so the initial navigation already reflects the authenticated user and nothing further is needed: -Authentication established later -- from the test method body, or with `setupBefore = TestExecutionEvent.TEST_EXECUTION` -- arrives after the initial navigation has already happened. Subsequent requests use the new authentication, but the view rendered during setup stays in place, so [methodname]`getCurrentView()` still returns the result of the earlier, anonymous navigation. +[source,java] +---- +@Test +@WithMockUser(username = "admin", roles = "ADMIN") +void adminOpensAdminView_avatarShown() { + AdminView view = navigate(AdminView.class); + + Assertions.assertTrue(find(Avatar.class).single().isVisible()); +} +---- + +See <> for the full setup. + +Some scenarios need the sign-in to happen during the test itself, such as a login form, or a view that has to be asserted both before and after the user signs in. Authentication established then -- from the test method body, or with `setupBefore = TestExecutionEvent.TEST_EXECUTION` -- arrives after the environment has navigated in step 3. Subsequent requests use the new authentication, but the view rendered during setup stays in place, so [methodname]`getCurrentView()` still returns the result of that earlier, anonymous navigation. Navigate again after signing in: @@ -90,8 +103,9 @@ Navigate again after signing in: @Test @WithMockUser(username = "admin", roles = "ADMIN", setupBefore = TestExecutionEvent.TEST_EXECUTION) -void adminSignsIn_adminViewShown() { - // The initial navigation ran while the user was still anonymous. +void adminSignsInDuringTest_adminViewShown() { + // Setup navigated to the root route while the user was still anonymous, + // and access control redirected that navigation to the login view. Assertions.assertInstanceOf(LoginView.class, getCurrentView()); // Navigating again applies access control to the current authentication. @@ -103,9 +117,7 @@ void adminSignsIn_adminViewShown() { .Reloading Isn't Enough [NOTE] -[methodname]`reload()` isn't a substitute for navigating again. It re-renders the current location, and when access control has redirected to the login view, that location is the login view. - -Where the scenario under test allows it, prefer applying the authentication before the test method, with a plain [annotationname]`@WithMockUser` or [annotationname]`@WithUserDetails`. The initial navigation then reflects the authenticated user, and no second navigation is needed. +Reloading with [methodname]`Page.reload()` isn't a substitute for navigating again. It recreates the [classname]`UI` and renders the location that is currently active, the same as pressing reload in a browser; when access control has redirected to the login view, that location is the login view. == Components That Don't Exist Yet diff --git a/articles/flow/testing/browserless/getting-started.adoc b/articles/flow/testing/browserless/getting-started.adoc index 43bd13daed..245ce9c562 100644 --- a/articles/flow/testing/browserless/getting-started.adoc +++ b/articles/flow/testing/browserless/getting-started.adoc @@ -199,20 +199,14 @@ To navigate to another registered view, use the [methodname]`navigate()` methods [methodname]`navigate(Template.class, Collections.singletonMap("param", PARAMETER))` + [methodname]`navigate("template/myParam", Template.class)` +- For a location that [since:com.vaadin:vaadin@V25.3]#carries a query string, a fragment, or both# ++ +[methodname]`navigate("myParam/parameter?tab=history&page=2", MyParam.class)` ++ +The location is split up the same way the browser address bar does it, so the query parameters reach the view through the navigation event. A location consisting only of a fragment, such as `"#details"`, identifies a place within the current page rather than a route, so it leaves the current view in place. All navigation methods return the instantiated view, so that its fields can be used directly for testing. -A location string can also [since:com.vaadin:vaadin@V25.3]#carry a query string, a fragment, or both#. They are split off from the path the same way the browser address bar does it, so the query parameters reach the view through the navigation event: - -[source,java] ----- -navigate("orders/1?tab=history&page=2", OrderView.class); ----- - -Query parameters can be passed as a separate [classname]`QueryParameters` object through [methodname]`UI.getCurrent().navigate(String, QueryParameters)`. Providing them both ways at once is rejected, because the query string in the location would be dropped. - -A location consisting only of a fragment, such as `"#details"`, identifies a place within the current page rather than a route, so it leaves the current view in place. - [NOTE] Navigation by location string takes in the view class, so that the initialized view can be automatically validated to be the expected one.