diff --git a/articles/flow/testing/browserless/environment-differences.adoc b/articles/flow/testing/browserless/environment-differences.adoc new file mode 100644 index 0000000000..6760543647 --- /dev/null +++ b/articles/flow/testing/browserless/environment-differences.adoc @@ -0,0 +1,128 @@ +--- +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: 80 +--- + + += 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. + +Inject an [classname]`ObjectProvider` instead, and ask it for the bean inside the test method, where the session is available: + +.Works +[source,java] +---- +@SpringBootTest +class CartViewTest extends SpringBrowserlessTest { + + @Autowired + private ObjectProvider cartProvider; + + @Test + void addItem_cartContainsItem() { + CartView view = navigate(CartView.class); + Cart cart = cartProvider.getObject(); + + test(view.addButton).click(); + + Assertions.assertEquals(1, cart.getItems().size()); + } +} +---- + +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. + + +[#authentication-applied-during-a-test] +== Authentication Applied During a Test + +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: + +[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: + +[source,java] +---- +@Test +@WithMockUser(username = "admin", roles = "ADMIN", + setupBefore = TestExecutionEvent.TEST_EXECUTION) +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. + navigate(AdminView.class); + + Assertions.assertTrue(find(Avatar.class).single().isVisible()); +} +---- + +.Reloading Isn't Enough +[NOTE] +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 + +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..245ce9c562 100644 --- a/articles/flow/testing/browserless/getting-started.adoc +++ b/articles/flow/testing/browserless/getting-started.adoc @@ -199,6 +199,11 @@ 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. 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]