diff --git a/articles/building-apps/testing/browserless/_hello-world-view.adoc b/articles/building-apps/testing/browserless/_hello-world-view.adoc new file mode 100644 index 0000000000..0e76996570 --- /dev/null +++ b/articles/building-apps/testing/browserless/_hello-world-view.adoc @@ -0,0 +1,19 @@ +.`HelloWorldView.java` +[source,java] +---- +@Route("") +public class HelloWorldView extends HorizontalLayout { + + TextField name; + Button sayHello; + + public HelloWorldView() { + name = new TextField("Your name"); + sayHello = new Button("Say hello"); + sayHello.addClickListener(e -> { + Notification.show("Hello " + name.getValue()); + }); + add(name, sayHello); + } +} +---- diff --git a/articles/building-apps/testing/browserless/configure-tests.adoc b/articles/building-apps/testing/browserless/configure-tests.adoc new file mode 100644 index 0000000000..e779127282 --- /dev/null +++ b/articles/building-apps/testing/browserless/configure-tests.adoc @@ -0,0 +1,93 @@ +--- +title: Configure a Browserless Test +page-title: Configure a Browserless Test | Vaadin +description: Set Vaadin properties and feature flags for individual browserless tests while retaining the correct framework setup. +meta-description: Set Vaadin properties and feature flags for individual browserless tests while retaining the correct framework setup. +order: 35 +--- + + += [since:com.vaadin:vaadin@V25.3]#Configure a Browserless Test# + +Use this guide after setting up a <<./#choose-your-framework,plain Java, Spring Boot, or Quarkus test>> with its standard environment initialization. +The Java EE/CDI guide replaces that initialization and does not automatically apply this configuration; retain its custom setup rather than copying these annotations into it. + +== Configure One Test Class + +Add `@BrowserlessTestConfig` to the existing test class to enable a feature and set a Vaadin deployment property. +For the plain Java setup: + +[source,java] +---- +@BrowserlessTestConfig( + applicationProperties = "devmode.sessionSerialization.enabled=true", + featureFlags = "defaultAutoResponsiveFormLayout") +class CartViewTest extends BrowserlessTest { + // Existing tests for CartView. +} +---- + +Keep `SpringBrowserlessTest` and `@SpringBootTest` for Spring Boot, or `QuarkusBrowserlessTest` and `@QuarkusTest` for Quarkus. +Choose an identifier from <<{articles}/flow/configuration/feature-flags#,Feature Flags>>; an unknown identifier fails instead of silently enabling a new feature. +Run the affected tests with `mvn test` and assert the application behavior under the selected setting. +The settings are local to the test environment, so the test does not need to edit a feature-flags file or reset system properties afterwards. + +== Override a Setting for One Method + +To exercise the same view with the feature disabled, add this annotation to the test method that verifies the fallback behavior: + +[source,java] +---- +@BrowserlessTestConfig(featureFlags = "defaultAutoResponsiveFormLayout=false") +---- + +The method-level flag replaces the class-level flag while retaining its application property. +Use a per-method environment for this pattern. `BrowserlessClassExtension` shares one environment and rejects method-level configuration. +See <<{articles}/flow/testing/browserless/test-configuration#merging-class-and-method-configuration,Configuration Merging>> for inheritance and precedence rules. + +== Override a Spring-Defined Property + +In a Spring Boot test, a Vaadin property already defined in the Spring environment takes precedence over `@BrowserlessTestConfig`. +Use Spring's `@TestPropertySource` on that test class to change it: + +[source,java] +---- +@SpringBootTest +@TestPropertySource(properties = + "vaadin.devmode.sessionSerialization.enabled=true") +class CartViewTest extends SpringBrowserlessTest { + // Existing tests for CartView. +} +---- + +This section is Spring-specific; it does not configure the Weld archive in the Java EE/CDI guide. + +== Configure an Extension or Multiple Users + +For a plain Java JUnit extension, configure the instance that creates the environment: + +[source,java] +---- +@RegisterExtension +BrowserlessExtension extension = new BrowserlessExtension() + .withFeatureFlags("defaultAutoResponsiveFormLayout"); +---- + +For an application context created in a multi-user test, configure its builder: + +[source,java] +---- +try (var app = BrowserlessApplicationContext.create(builder -> builder + .withViewPackages(CartView.class) + .withFeatureFlags("defaultAutoResponsiveFormLayout"))) { + var window = app.newUser().newWindow(); + window.navigate(CartView.class); + // Exercise the view and assert the behavior enabled by the flag. +} +---- + +An application context created this way does not read annotations on the test class. +See <<{articles}/flow/testing/browserless/test-configuration#configuring-without-annotations,Programmatic Configuration>> for explicitly importing a configuration, registering Lookup services, and combining programmatic settings with annotations. + + +[discussion-id]`C941CDE1-ACC5-47F3-82D9-90A3D1D0C1FC` diff --git a/articles/building-apps/testing/browserless/debug-tests.adoc b/articles/building-apps/testing/browserless/debug-tests.adoc new file mode 100644 index 0000000000..071d0ce693 --- /dev/null +++ b/articles/building-apps/testing/browserless/debug-tests.adoc @@ -0,0 +1,119 @@ +--- +title: Debug a Failing Browserless Test +page-title: Debug a Failing Browserless Test | Vaadin +description: Enable failure snapshots, inspect component state, and fix a failing browserless interaction test. +meta-description: Enable failure snapshots, inspect component state, and fix a failing browserless interaction test. +order: 90 +--- + + += Debug a Failing Browserless Test + +Start with a configured test class from <<./#choose-your-framework,the guide for your application framework>>. +Keep its base class and setup when adding the snapshot annotation. +The example below uses a directly constructed view and the plain Java setup. + +When a browserless test fails, it can be hard to tell why. The assertion message might say a component wasn't found or had an unexpected value, but it doesn't show you what the UI _actually_ looked like. UI snapshots solve this by printing a text representation of the entire component tree at the moment of failure, so you can see exactly what was on screen. + + +== Enabling Snapshots + +Snapshots aren't enabled by default. Add the [annotationname]`@ExtendWith(TreeOnFailureExtension.class)` annotation to your test class: + +[source,java] +---- +@ExtendWith(TreeOnFailureExtension.class) +class HelloWorldViewTest extends BrowserlessTest { + ... +} +---- + +For Java EE/CDI, add the same annotation to the existing CDI-aware test class: + +[source,java] +---- +@ExtendWith(TreeOnFailureExtension.class) +class CdiGreetingViewTest extends AbstractCdiViewTest { + // Keep the test methods from the CDI guide. +} +---- + +When any test in the class fails, the extension automatically prints the UI tree to the test output alongside the failure message. + + +See <<{articles}/flow/testing/browserless/snapshots#reading-a-snapshot,Snapshot Format>> for the properties represented in the output. + + +== Using Snapshots to Debug Failures + +Suppose you have a view like this: + +[source,java] +---- +@Route("") +public class HelloWorldView extends HorizontalLayout { + + TextField name; + Button sayHello; + + public HelloWorldView() { + name = new TextField("Your name"); + sayHello = new Button("Say hello"); + sayHello.addClickListener(e -> { + if (!name.getValue().isEmpty()) { + Notification.show("Hello " + name.getValue()); + } + }); + add(name, sayHello); + } +} +---- + +And a test for it: + +[source,java] +---- +@Test +public void clickSayHello_showsGreeting() { + HelloWorldView view = navigate(HelloWorldView.class); + test(view.sayHello).click(); + Notification notification = find(Notification.class).single(); + assertEquals("Hello World", test(notification).getText()); +} +---- + +The test fails because no [classname]`Notification` was found. The assertion error alone doesn't explain why. With snapshots enabled, the test output includes the UI tree: + +---- +└── UI[] + └── HelloWorldView[@theme='margin spacing'] + ├── TextField[label='Your name', value=''] + └── Button[caption='Say hello'] +---- + +Now you can see: + +- There's no [classname]`Notification` in the tree -- the click didn't produce one. +- The [classname]`TextField` has `value=''` -- the name field is empty. +- Looking back at the view code, the click handler only opens the notification when the name is nonempty. The test forgot to set the name before clicking. + +Set the name before clicking the button, then rerun the test: + +[source,java] +---- +test(view.name).setValue("World"); +test(view.sayHello).click(); +Notification notification = find(Notification.class).single(); +Assertions.assertEquals("Hello World", test(notification).getText()); +---- + + +== Tips + +- **Look for what's missing.** If a query like `find(Notification.class).single()` fails, the snapshot shows you that the component isn't there. Check the tree for clues about why it wasn't created. +- **Check property values.** When an assertion on a component's text or value fails, find that component in the tree and compare its actual properties to what you expected. +- **Watch for unexpected components.** If `find(Button.class).single()` fails because multiple buttons were found, the snapshot shows you all of them so you can narrow your query. +- **Inspect the layout hierarchy.** If a component appears in the tree but a scoped query like `findInView(TextField.class)` can't find it, the snapshot helps you see whether the component is nested inside the expected parent. + + +[discussion-id]`7B14417A-0C9B-4B46-8945-AC2564AD5F82` diff --git a/articles/building-apps/testing/browserless/index.adoc b/articles/building-apps/testing/browserless/index.adoc new file mode 100644 index 0000000000..f8623c8173 --- /dev/null +++ b/articles/building-apps/testing/browserless/index.adoc @@ -0,0 +1,57 @@ +--- +title: Browserless Testing +page-title: Browserless Testing | Vaadin +description: Choose the browserless test setup for Spring Boot, Java EE/CDI, Quarkus, or plain Java, then test your Flow views. +meta-description: Choose the browserless test setup for Spring Boot, Java EE/CDI, Quarkus, or plain Java, then test your Flow views. +order: 10 +--- + + += Browserless Testing + +Browserless tests exercise Flow views and components in the test JVM. +Choose the setup that matches your application's dependency injection framework before writing a test. + +[#choose-your-framework] +== Choose Your Framework + +[cols="1,2,2"] +|=== +| Application | Setup Guide | Test Environment +| Spring Boot +| Start with [[first-browserless-test]][[navigating-to-a-view]][[using-the-java-api-directly]][[simulating-user-actions-with-testers]][[finding-components]][[running-tests]]<> +| `SpringBrowserlessTest` with `@SpringBootTest` and Spring-managed beans. +| Java EE / Jakarta EE with Vaadin CDI +| <> +| An application-owned `AbstractCdiViewTest` extending `BrowserlessTest`, with Weld and `CdiVaadinServlet`. +| Quarkus +| <> +| `QuarkusBrowserlessTest` with `@QuarkusTest`. +| Plain Java without a dependency injection container +| <> +| `BrowserlessTest` or a browserless JUnit extension. +|=== + +The CDI guide uses Jakarta packages (`jakarta.*`), as used by current Vaadin applications. +Quarkus has its own integration even though it also uses CDI concepts. + +== Keep the Framework Setup with the Test + +Use one setup path for each test class. +Keep that path's base class, dependencies, lifecycle hooks, and bean configuration when adapting an interaction example. + +* Spring Boot tests use Spring configuration and Spring Security test annotations where applicable. +* Java EE/CDI tests use a Weld test archive, CDI producers, scopes, and alternatives. They extend the CDI-aware base class from the CDI guide. Adding Spring annotations or `browserless-test-spring` does not configure CDI. +* Quarkus tests use Quarkus test profiles and security annotations. +* Plain Java examples construct components without a dependency injection container. Extending `BrowserlessTest` alone does not enable CDI injection. + +Methods such as `navigate()`, `find()`, and `test()` are shared interaction APIs. +A shared method does not make the surrounding framework setup interchangeable. +The task guides identify their example setup and explain which parts to keep when using CDI. + +== Guides + +section_outline::[] + + +[discussion-id]`E6858195-4B1D-43C8-8688-502531C6AC16` diff --git a/articles/flow/testing/browserless/migration.adoc b/articles/building-apps/testing/browserless/migrate-ui-unit-tests.adoc similarity index 80% rename from articles/flow/testing/browserless/migration.adoc rename to articles/building-apps/testing/browserless/migrate-ui-unit-tests.adoc index a214090e67..3c4c652158 100644 --- a/articles/flow/testing/browserless/migration.adoc +++ b/articles/building-apps/testing/browserless/migrate-ui-unit-tests.adoc @@ -1,13 +1,13 @@ --- -title: Migrating from UI Unit Testing -page-title: How to migrate from UI Unit Testing to Browserless Testing | Vaadin -description: Steps for migrating existing tests from the old UI Unit Testing module to the new Browserless Testing module. -meta-description: Migrate Vaadin UI Unit Tests to Browserless Testing. Covers dependency changes, base class replacements, and TestBench coexistence. -order: 90 +title: Migrate UI Unit Tests to Browserless Tests +page-title: Migrate UI Unit Tests to Browserless Tests | Vaadin +description: Update UI Unit Testing dependencies and base classes, handle Spring support, and migrate existing JUnit tests. +meta-description: Update UI Unit Testing dependencies and base classes, handle Spring support, and migrate existing JUnit tests. +order: 110 --- -= Migrating from UI Unit Testing += Migrate UI Unit Tests to Browserless Tests Browserless testing replaces the older UI Unit Testing module. The testing API is the same; only the dependency and base class names have changed. @@ -46,7 +46,7 @@ Replace your existing UI Unit Testing dependency with `browserless-test-junit6`. ---- -For Quarkus-based projects, also add `browserless-test-quarkus`. See <> for details. +For Quarkus-based projects, also add `browserless-test-quarkus`. See <> for details. [role="since:com.vaadin:vaadin@V25.2"] @@ -70,6 +70,10 @@ No code changes are needed; class names and packages are unchanged. Non-Spring p == Replace Base Classes +Select the base class matching your application framework. +Java EE/CDI tests need the custom initialization in <>; the plain `BrowserlessTest` replacement alone does not enable CDI. + + Rename the test base classes as follows: [cols="1,1,1"] @@ -96,7 +100,7 @@ If you're migrating from JUnit 4 (`vaadin-testbench-unit`), you also need to upd == TestBench End-to-End Coexistence -Browserless tests and TestBench end-to-end tests can coexist in the same project. The two modules use separate package namespaces, so having both `browserless-test-junit6` and `vaadin-testbench` on the test classpath doesn't cause conflicts. +Browserless tests and TestBench end-to-end tests can coexist in the same project. The two modules use separate Java packages, so having both `browserless-test-junit6` and `vaadin-testbench` on the test classpath doesn't cause conflicts. == Keeping JUnit 4 Tests diff --git a/articles/building-apps/testing/browserless/setup-cdi.adoc b/articles/building-apps/testing/browserless/setup-cdi.adoc new file mode 100644 index 0000000000..db83d12e84 --- /dev/null +++ b/articles/building-apps/testing/browserless/setup-cdi.adoc @@ -0,0 +1,323 @@ +--- +title: Set Up Browserless Tests with Java EE/CDI +page-title: Set Up Browserless Tests with Java EE/CDI | Vaadin +description: Use Weld and Vaadin CDI to inject dependencies into browserless view tests, select test alternatives, and manage the test lifecycle. +meta-description: Use Weld and Vaadin CDI to inject dependencies into browserless view tests, select test alternatives, and manage the test lifecycle. +order: 25 +--- + + += Set Up Browserless Tests with Java EE/CDI + +Use this guide for a Java EE / Jakarta EE application that uses the Vaadin CDI add-on. +The examples use `jakarta.*` APIs and JUnit Jupiter. +They run Weld in the test JVM and use `CdiVaadinServlet` to create views through CDI. + +The test base class in this guide, `AbstractCdiViewTest`, is application code that you add under `src/test/java`. +It extends `BrowserlessTest`; there is no Spring application context in this setup. +Keep this base class when adapting examples from the other testing guides. + +== Add Test Dependencies + +Your application must already have <<{articles}/flow/integrations/cdi#add-dependencies,Vaadin CDI and the provided Jakarta EE APIs>> configured. +Keep those dependencies and the Vaadin BOM. +Add the following test dependencies to the module containing your views: + +[source,xml] +---- + + com.vaadin + browserless-test-junit6 + test + + + org.jboss.weld + weld-junit5 + 5.0.3.Final + test + + + org.junit.jupiter + junit-jupiter + test + +---- + +If your parent POM does not already manage JUnit, add this import to its existing dependency-management section: + +[source,xml] +---- + + + + + org.junit + junit-bom + 6.0.3 + pom + import + + + +---- +The example uses JUnit 6, matching `browserless-test-junit6`; Weld's integration artifact is still named `weld-junit5`. +Use a Browserless Test release with the lifecycle hooks shown below; the source project uses `browserless-test-junit6` 1.1.2 and JUnit 6.0.3. +A project that manages Browserless Test separately can import `com.vaadin:browserless-test-bom` in dependency management to align its modules. + +A deployed WAR uses its `WEB-INF/beans.xml` and application-server discovery. +These tests do not deploy that WAR. The annotations on the test base class configure a separate Weld test archive. + +== Create a View with a CDI Dependency + +This example injects a greeting service into a view. +Put each public type in its own file under `src/main/java/com/example/app`. +The session-scoped service makes scope activation part of the test setup. + +.`GreetingService.java` +[source,java] +---- +package com.example.app; + +public interface GreetingService { + String greet(String name); +} +---- + +.`DefaultGreetingService.java` +[source,java] +---- +package com.example.app; + +import java.io.Serializable; +import jakarta.enterprise.context.SessionScoped; + +@SessionScoped +public class DefaultGreetingService implements GreetingService, Serializable { + @Override + public String greet(String name) { + return "Hello " + name; + } +} +---- + +.`CdiGreetingView.java` +[source,java] +---- +package com.example.app; + +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Route; + +@Route("cdi-greeting") +@Dependent +public class CdiGreetingView extends Div { + @Inject + public CdiGreetingView(GreetingService greetings) { + TextField name = new TextField("Your name"); + Button greet = new Button("Say hello", event -> + Notification.show(greetings.greet(name.getValue()))); + add(name, greet); + } +} +---- + +== Create the CDI-Aware Test Base Class + +Put this class under `src/test/java/com/example/app/testing`. +Use the default JUnit per-method test-instance lifecycle. +The dedicated test package keeps Weld's automatic package scan separate from the application package; list application beans explicitly. + +.`AbstractCdiViewTest.java` +[source,java] +---- +package com.example.app.testing; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.SessionScoped; +import jakarta.enterprise.inject.Produces; +import org.jboss.weld.bootstrap.spi.BeanDiscoveryMode; +import org.jboss.weld.junit5.auto.ActivateScopes; +import org.jboss.weld.junit5.auto.AddBeanClasses; +import org.jboss.weld.junit5.auto.AddExtensions; +import org.jboss.weld.junit5.auto.AddPackages; +import org.jboss.weld.junit5.auto.EnableAutoWeld; +import org.jboss.weld.junit5.auto.SetBeanDiscoveryMode; +import org.junit.jupiter.api.BeforeEach; +import com.example.app.CdiGreetingView; +import com.example.app.DefaultGreetingService; +import com.vaadin.browserless.BrowserlessTest; +import com.vaadin.browserless.internal.MockVaadin; +import com.vaadin.browserless.mocks.MockedUI; +import com.vaadin.cdi.CdiInstantiator; +import com.vaadin.cdi.CdiVaadinServlet; +import com.vaadin.cdi.VaadinExtension; +import com.vaadin.cdi.util.BeanManagerProvider; +import com.vaadin.flow.router.RouteConfiguration; + +@EnableAutoWeld +@SetBeanDiscoveryMode(BeanDiscoveryMode.ALL) +@AddPackages(CdiInstantiator.class) +@ActivateScopes(SessionScoped.class) +@AddBeanClasses({ CdiGreetingView.class, DefaultGreetingService.class }) +@AddExtensions({ BeanManagerProvider.class, VaadinExtension.class }) +public abstract class AbstractCdiViewTest extends BrowserlessTest { + @Produces + @ApplicationScoped + private final CdiVaadinServlet vaadinServlet = new CdiVaadinServlet(); + + @BeforeEach + @Override + protected void initVaadinEnvironment() { + scanTesters(); + MockVaadin.setup(MockedUI::new, vaadinServlet, lookupServices()); + initSignalsSupport(); + RouteConfiguration.forApplicationScope() + .setAnnotatedRoute(CdiGreetingView.class); + } +} +---- + +Keep `@BeforeEach` on the override: Weld starts before this JUnit lifecycle method creates the Vaadin environment. +The produced servlet connects the mocked Vaadin service to CDI. +Call `initSignalsSupport()` to retain the default signal-testing behavior when replacing the base setup. +The inherited `@AfterEach` cleanup releases the Vaadin environment and signal support before Weld shuts down. + +This custom setup does not automatically apply <<{articles}/flow/testing/browserless/test-configuration#,`@BrowserlessTestConfig`>> settings. +Its direct `lookupServices()` call follows the source example; that hook is deprecated in newer releases in favor of configuration for the standard setup. +See <<{articles}/flow/testing/browserless/cdi#custom-setup-and-test-configuration,Custom Setup and Test Configuration>> before adding per-test properties or flags. + +This override registers routes explicitly after creating the CDI-aware environment. +It does not call the default route-discovery setup. +For another view, add its concrete CDI dependencies to `@AddBeanClasses` and register the route here. +Include layouts, producers, observers, and transitive dependencies needed by that view. + +`@ActivateScopes(SessionScoped.class)` activates the context required by the greeting service. +Add other scopes only if the tested bean graph needs them. +See <<{articles}/flow/testing/browserless/cdi#,CDI Test Integration>> for discovery rules and lifecycle details. + +== Write and Run the View Test + +Put this class in the same test package as `AbstractCdiViewTest`: + +.`CdiGreetingViewTest.java` +[source,java] +---- +package com.example.app.testing; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.example.app.CdiGreetingView; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.textfield.TextField; + +class CdiGreetingViewTest extends AbstractCdiViewTest { + @Test + void greeting_usesInjectedService() { + navigate(CdiGreetingView.class); + test(find(TextField.class).withLabel("Your name").single()) + .setValue("Ada"); + test(find(Button.class).withText("Say hello").single()).click(); + assertEquals("Hello Ada", + test(find(Notification.class).single()).getText()); + } +} +---- + +Run `mvn test` in the module containing the tests. +For a multi-module project, run `mvn -pl your-ui-module -am test` from the project root. +The assertion checks that navigation created the view through CDI and that the injected service handled the interaction. +No application server or browser is needed for this test. + +[#test-alternatives] +== Replace a Service with a CDI Alternative + +To make a collaborator deterministic, add a concrete test implementation under `src/test/java/com/example/app/testing`: + +.`TestGreetingService.java` +[source,java] +---- +package com.example.app.testing; + +import java.io.Serializable; +import jakarta.enterprise.context.SessionScoped; +import jakarta.enterprise.inject.Alternative; +import com.example.app.GreetingService; + +@Alternative +@SessionScoped +public class TestGreetingService implements GreetingService, Serializable { + @Override + public String greet(String name) { + return "Test greeting for " + name; + } +} +---- + +Register and enable it for the test that needs the replacement: + +.`AlternativeGreetingViewTest.java` +[source,java] +---- +package com.example.app.testing; + +import org.jboss.weld.junit5.auto.AddBeanClasses; +import org.jboss.weld.junit5.auto.EnableAlternatives; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.example.app.CdiGreetingView; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.textfield.TextField; + +@AddBeanClasses(TestGreetingService.class) +@EnableAlternatives(TestGreetingService.class) +class AlternativeGreetingViewTest extends AbstractCdiViewTest { + @Test + void greeting_usesTestAlternative() { + navigate(CdiGreetingView.class); + test(find(TextField.class).withLabel("Your name").single()) + .setValue("Ada"); + test(find(Button.class).withText("Say hello").single()).click(); + assertEquals("Test greeting for Ada", + test(find(Notification.class).single()).getText()); + } +} +---- + +Register the concrete implementation, rather than `GreetingService.class` alone. +Adding two ordinary implementations with identical types and qualifiers creates an ambiguous injection point. +The selected CDI alternative replaces the ordinary bean for this test archive. +Spring's `@Bean`, `@MockBean`, and `@MockitoBean` are not part of this configuration. + +== Troubleshoot the Setup + +[cols="1,2"] +|=== +| Symptom | Check +| Missing route | Register the route after `MockVaadin.setup()`. Register CDI beans and routes separately. +| Unsatisfied CDI dependency | Add the concrete bean, producer, or required package to the Weld archive; follow the full injection graph. +| Ambiguous CDI dependency | Enable one test alternative for the bean type, or use the application's qualifiers. +| Inactive session context | Activate `SessionScoped` for the test. +| View created without CDI injection | Use `CdiVaadinServlet`, both CDI extensions, and the annotated setup override. Do not initialize the default environment first. +|=== + +== Continue with Interaction Tests + +Use <> and <> with `AbstractCdiViewTest` as the base class. +For signal-based views, this setup also initializes the support used by <>. + +For authentication scenarios, register your application's login view, access-control beans, and route listeners in this CDI setup, then exercise its login behavior with component testers. +Spring Security's `@WithMockUser` and Quarkus's `@TestSecurity` do not configure authentication for this Weld test archive. +These tests cover server-side application behavior; use deployment or browser tests to verify application-server security and browser-only behavior. + +The CDI setup is adapted from the https://github.com/TatuLund/bookstore-flow-ee/wiki/How-to-Use-Vaadin-BrowserlessTest-in-a-Java-EE-CDI-Project[Bookstore CDI testing guide]. +The https://github.com/TatuLund/bookstore-flow-ee/blob/v25/bookstore-starter-flow-ui/src/test/java/com/vaadin/samples/AbstractViewTest.java[Bookstore test base class] provides a larger example with application-specific authentication and routes. + + +[discussion-id]`520A0F7B-E7A9-4D77-97B1-ED3AA78B4CBF` diff --git a/articles/building-apps/testing/browserless/setup-quarkus.adoc b/articles/building-apps/testing/browserless/setup-quarkus.adoc new file mode 100644 index 0000000000..6faf156da2 --- /dev/null +++ b/articles/building-apps/testing/browserless/setup-quarkus.adoc @@ -0,0 +1,97 @@ +--- +title: Set Up Browserless Tests with Quarkus +page-title: Set Up Browserless Tests with Quarkus | Vaadin +description: Configure Quarkus browserless tests, create a test class, and substitute services using test profiles. +meta-description: Configure Quarkus browserless tests, create a test class, and substitute services using test profiles. +order: 30 +--- + + += Set Up Browserless Tests with Quarkus + +Use this guide for an existing Quarkus Flow application. +Use `QuarkusBrowserlessTest` and Quarkus test profiles throughout this setup. +For a Java EE application using Vaadin CDI and Weld, use <>. + +== Add Dependencies + +With the Vaadin BOM imported, add these test dependencies to your [filename]`pom.xml` file: + +.pom.xml +[source,xml] +---- + + com.vaadin + browserless-test-junit6 + test + + + com.vaadin + browserless-test-quarkus + test + + + io.quarkus + quarkus-junit5 + test + +---- + +== Create and Configure the Test + +Create this view and put its test in the same Java package under `src/test/java`: + +include::{root}/articles/building-apps/testing/browserless/_hello-world-view.adoc[] + +.Quarkus Test Example +[source,java] +---- +@QuarkusTest +class ViewTest extends QuarkusBrowserlessTest { + @Test + public void setText_clickButton_notificationIsShown() { + final HelloWorldView helloView = navigate(HelloWorldView.class); + + test(helloView.name).setValue("Test"); + test(helloView.sayHello).click(); + + Notification notification = find(Notification.class).single(); + Assertions.assertEquals("Hello Test", test(notification).getText()); + } +} +---- + +[NOTE] +With [annotationname]`@QuarkusTest` annotation, the testing framework starts the application and the HTTP server -- although it won't be required for browserless testing. However, [classname]`QuarkusBrowserlessTest` tests are still executed in a mocked environment. + +A test can be annotated with [annotationname]`@TestProfile` to reference a specific test configuration. With a test profile you can, for example, override application configuration, provide bean alternatives and custom test resources. Refer to the https://quarkus.io/guides/getting-started-testing#testing_different_profiles[Quarkus Testing Profiles documentation] for additional information. + +.Quarkus Testing Profile Example +[source,java] +---- +public class MockServiceProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Collections.singletonMap("app.some.config","value"); + } + + @Override + public Set> getEnabledAlternatives() { + return Collections.singleton(MockService.class); + } +} + +@QuarkusTest +@TestProfile(MockServiceProfile.class) +class ViewTest extends QuarkusBrowserlessTest { +} +---- + +== Run the Test + +Run the test from your IDE or with `mvn test`. +To test protected views, continue with <<{articles}/building-apps/testing/browserless/test-view-access#quarkus,Test View Access Control with Quarkus>>. + + +[discussion-id]`09A26994-97AB-4877-AEB1-717BE02E348D` diff --git a/articles/building-apps/testing/browserless/setup-spring-boot.adoc b/articles/building-apps/testing/browserless/setup-spring-boot.adoc new file mode 100644 index 0000000000..ef45d7aacc --- /dev/null +++ b/articles/building-apps/testing/browserless/setup-spring-boot.adoc @@ -0,0 +1,175 @@ +--- +title: Set Up Browserless Tests with Spring Boot +page-title: Set Up Browserless Tests with Spring Boot | Vaadin +description: Set up a Spring Boot project, write a browserless test for a Flow view, and run it from your IDE or Maven. +meta-description: Set up a Spring Boot project, write a browserless test for a Flow view, and run it from your IDE or Maven. +order: 10 +--- + + += Set Up Browserless Tests with Spring Boot + +To start creating browserless tests in an existing Spring Boot project, add the `browserless-test-spring` dependency with a `test` scope. Spring Boot's test starter must also be present -- it's typically already on the classpath in Spring Boot projects. + +This guide uses Spring Boot, `SpringBrowserlessTest`, and the Spring application context. +For another application framework, choose its guide in <<./#choose-your-framework,Browserless Testing>>. + +Assuming you've imported the Vaadin Bill-of-Materials (BOM) and have a Maven project, add the following: + +[source,xml] +---- + + com.vaadin + browserless-test-spring + test + + + org.springframework.boot + spring-boot-starter-test + test + +---- + + +[#first-browserless-test] +== Create a Test + +In Spring Boot projects, views typically use dependency injection for services and other components. To handle this correctly, browserless testing provides a specialized base class: [classname]`SpringBrowserlessTest`. Annotate your test class with [annotationname]`@SpringBootTest` so that the full application context is available. + +Given a simple view like this: + +include::{root}/articles/building-apps/testing/browserless/_hello-world-view.adoc[] + +A browserless test for it looks like this: + +.`HelloWorldViewTest.java` +[source,java] +---- +@SpringBootTest +class HelloWorldViewTest extends SpringBrowserlessTest { + + @Test + public void setText_clickButton_notificationIsShown() { + final HelloWorldView helloView = navigate(HelloWorldView.class); + + test(helloView.name).setValue("Test"); + test(helloView.sayHello).click(); + + Notification notification = find(Notification.class).single(); + Assertions.assertEquals("Hello Test", test(notification).getText()); + } + +} +---- + +Place the test in `src/test/java`, in the same Java package as the view. +The following sections break down what this test does. + + +=== Navigating to a View + +The [methodname]`navigate()` method opens a view, as a user would navigate to it in the browser. It returns the view instance so you can interact with it directly. + +[source,java] +---- +final HelloWorldView helloView = navigate(HelloWorldView.class); +---- + + +=== Using the Java API Directly + +Since you're running on the server side, you have direct access to the Java component API. In the example above, the [classname]`TextField` and [classname]`Button` fields are package-protected. This means the test class can access them directly, as long as it's in the same Java package -- for example, if the view is in `src/main/java/com/example/app/`, put the test in `src/test/java/com/example/app/`. + +[source,java] +---- +// Read a component's value directly +String currentValue = helloView.name.getValue(); + +// Check component state +boolean isEnabled = helloView.sayHello.isEnabled(); +boolean isVisible = helloView.name.isVisible(); +---- + + +=== Simulating User Actions with Testers + +To simulate how a user interacts with a component, wrap it with [methodname]`test()`. This returns a component-specific tester that provides methods like [methodname]`setValue()`, [methodname]`click()`, and [methodname]`getText()`. Unlike calling the Java API directly, tester methods also verify that the component is in a usable state -- visible, enabled, and attached to the UI. + +[source,java] +---- +// Simulate typing into a text field +test(helloView.name).setValue("Test"); + +// Simulate clicking a button +test(helloView.sayHello).click(); + +// Read the text a user would see +String text = test(notification).getText(); +---- + +Each Vaadin component has a tester tailored to its behavior. For example, a [classname]`CheckboxTester` uses [methodname]`click()` to toggle checked state, a [classname]`ComboBoxTester` has [methodname]`selectItem()`, and a [classname]`GridTester` has [methodname]`getRow()`. See <<{articles}/flow/testing/browserless/component-testers#,Component Testers>> for supported operations and <> to build your own tester. + + +=== Finding Components + +Not every component is stored in a view field. For example, the [classname]`Notification` in the test above is created inside a click listener and isn't referenced anywhere in the view. Use the [methodname]`find()` query method to find components in the UI by their type: + +[source,java] +---- +// Find the single Notification currently open +Notification notification = find(Notification.class).single(); +---- + +The query API supports filtering by properties, predicates, and scoping to specific parts of the component tree. See <<{articles}/flow/testing/browserless/component-query#, Querying Components>> for details. + +== Running Tests + +Testing with [classname]`SpringBrowserlessTest` doesn't require any particular setup beyond the dependencies above. Run the test directly from your IDE or use Maven, for example by typing `mvn test` in the terminal. + +The test passes when entering a name and clicking the button produces the expected notification. + +== Next Steps + +Use <<{articles}/building-apps/testing/browserless/test-user-interactions#,Test User Interactions>> to cover more complex views. +If the test fails, see <<{articles}/building-apps/testing/browserless/debug-tests#,Debug a Failing Browserless Test>>. +For the environment lifecycle and navigation API, see <<{articles}/flow/testing/browserless/test-environment#,Test Environment and Lifecycle>>. + + + + +[#session-scoped-beans] +== Access Session-Scoped Beans + +A Spring test field is injected before the browserless Vaadin session exists. +If a test needs an application bean with `@VaadinSessionScope` or Spring's `@SessionScope`, inject an `ObjectProvider` and resolve the bean after environment setup: + +[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()); + } +} +---- + + +This example assumes a session-scoped `Cart` service and a `CartView` whose `addButton` adds an item. +Import `org.springframework.beans.factory.ObjectProvider` and `org.springframework.beans.factory.annotation.Autowired` for the test fields. +Resolve the bean in the test method, or in a subclass `@BeforeEach` method after the inherited environment setup. +See <<{articles}/flow/testing/browserless/environment-differences#session-scoped-beans,Spring Session-Scoped Beans>> for scope guarantees and other deferred-lookup options. + +This is Spring configuration. For CDI session beans, retain the scope activation and bean archive in <>. + + +[discussion-id]`7F423DA0-1C41-44BA-B832-55C269FA9311` diff --git a/articles/building-apps/testing/browserless/setup-without-spring.adoc b/articles/building-apps/testing/browserless/setup-without-spring.adoc new file mode 100644 index 0000000000..83d3145c45 --- /dev/null +++ b/articles/building-apps/testing/browserless/setup-without-spring.adoc @@ -0,0 +1,96 @@ +--- +title: Set Up Browserless Tests in Plain Java +page-title: Set Up Browserless Tests in Plain Java | Vaadin +description: Configure browserless tests in a plain Java project using a base class or a JUnit extension. +meta-description: Configure browserless tests in a plain Java project using a base class or a JUnit extension. +order: 20 +--- + + += Set Up Browserless Tests in Plain Java + +Use this setup when the view and its collaborators can be constructed without a dependency injection container. +For Java EE/CDI injection, use <>; for Quarkus, use <>. +`BrowserlessTest` alone creates the Vaadin environment, not an application dependency injection container. + + +== Dependencies + +Add the `browserless-test-junit6` dependency with a `test` scope. Assuming you have imported the Vaadin Bill-of-Materials (BOM) and have a Maven project, all you need is: + +[source,xml] +---- + + com.vaadin + browserless-test-junit6 + test + +---- + +No other test framework dependencies are required. + + +== Writing Tests + +Create this view, then place its test in the same Java package under `src/test/java`: + +include::{root}/articles/building-apps/testing/browserless/_hello-world-view.adoc[] + +Create a test class that extends [classname]`BrowserlessTest`: + +[source,java] +---- +class HelloWorldViewTest extends BrowserlessTest { + + @Test + public void setText_clickButton_notificationIsShown() { + final HelloWorldView helloView = navigate(HelloWorldView.class); + + test(helloView.name).setValue("Test"); + test(helloView.sayHello).click(); + + Notification notification = find(Notification.class).single(); + Assertions.assertEquals("Hello Test", test(notification).getText()); + } + +} +---- + +Use `navigate()`, `find()`, and `test()` for interactions as shown above. + + +== Use an Extension Instead of a Base Class + +If your test already extends another class, register an instance of `BrowserlessExtension`. +Use the same dependency as above. + +[source,java] +---- +@ViewPackages(classes = CartView.class) +class CartViewTest { + + @RegisterExtension + BrowserlessExtension ext = new BrowserlessExtension(); + + @Test + void addItemToCart() { + ext.navigate(CartView.class); + ext.findButton().withText("Add to cart").click(); + + Assertions.assertEquals("1 item", + ext.findSpan().withId("cart-size").getText()); + } +} +---- + +Use methods on the extension instance to navigate and interact with components. +The example assumes a `CartView` with an “Add to cart” button and a span with ID `cart-size`. +For lifecycle and configuration options, see <<{articles}/flow/testing/browserless/extensions#,JUnit 6 Extensions>>. + +== Run the Test + +Run the test from your IDE or with `mvn test`. +Continue with <<{articles}/building-apps/testing/browserless/test-user-interactions#,Test User Interactions>>. + + +[discussion-id]`D68CAC9E-6131-45C9-84E6-6D1CA1E44E81` diff --git a/articles/building-apps/testing/browserless/speed-up-tests.adoc b/articles/building-apps/testing/browserless/speed-up-tests.adoc new file mode 100644 index 0000000000..3edc49aa10 --- /dev/null +++ b/articles/building-apps/testing/browserless/speed-up-tests.adoc @@ -0,0 +1,156 @@ +--- +title: Speed Up Browserless Tests +page-title: Speed Up Browserless Tests | Vaadin +description: Reduce scanning and Spring context startup costs, and evaluate shared test environments without losing test isolation. +meta-description: Reduce scanning and Spring context startup costs, and evaluate shared test environments without losing test isolation. +order: 100 +--- + + += Speed Up Browserless Tests + +This guide covers default route scanning, Spring application contexts, and plain Java environment sharing. +For Java EE/CDI, keep the Weld setup in <>; tune its explicit bean archive and route registrations instead of applying Spring configuration or replacing it with a plain JUnit extension. + +By default, browserless tests scan the entire classpath for routes and error views and, in Spring Boot projects, load the full application context. For large projects this can slow down test startup. The following techniques help reduce bootstrap time. + + +== Restrict Package Scanning + +This applies to the default route-discovery setup. The CDI guide overrides that setup and registers routes explicitly. +The example below is for Spring Boot. + +Use a class from each view package to keep the scan focused and safe to refactor: + +[source,java] +---- +@SpringBootTest +@ViewPackages(classes = MyView.class) +class MyViewTest extends SpringBrowserlessTest { +} +---- + +See <<{articles}/flow/testing/browserless/test-environment#route-scanning,Route Scanning>> for all annotation forms. + + +[#using-a-reduced-application-context] +== Use a Reduced Spring Application Context + +Instead of [annotationname]`@SpringBootTest`, which loads the full application context, you can annotate the test with [annotationname]`@ContextConfiguration` to provide only the beans needed for the test. This is useful when you want to replace real services with test doubles. + +For a view that injects a service, register a test implementation with the same interface. +This example uses a service-backed variant of the greeting view: + +[source,java] +---- +public interface GreetingService { + String greet(String name); +} + +@Route("service-greeting") +public class ServiceGreetingView extends HorizontalLayout { + final TextField name = new TextField("Your name"); + final Button sayHello = new Button("Say hello"); + + public ServiceGreetingView(GreetingService greetings) { + sayHello.addClickListener(event -> + Notification.show(greetings.greet(name.getValue()))); + add(name, sayHello); + } +} +---- + +Place each public type in its own file. Put the test in the view's package: + +[source,java] +---- +@ViewPackages(classes = ServiceGreetingView.class) +@ContextConfiguration(classes = ViewTestConfig.class) +class ViewTest extends SpringBrowserlessTest { + @Test + void greeting_usesTestService() { + var view = navigate(ServiceGreetingView.class); + test(view.name).setValue("Test"); + test(view.sayHello).click(); + Notification notification = find(Notification.class).single(); + Assertions.assertEquals("Hello Test", test(notification).getText()); + } +} + +@Configuration +class ViewTestConfig { + @Bean + GreetingService greetingService() { + return name -> "Hello " + name; + } +} +---- + + +[NOTE] +==== +Prefer replacing services this way -- a test [annotationname]`@Configuration` selected with [annotationname]`@ContextConfiguration` -- over bean overrides such as [annotationname]`@MockitoBean` or [annotationname]`@MockBean`, especially when other test classes in the same run authenticate with [annotationname]`@WithUserDetails`, [annotationname]`@WithMockUser`, or a similar annotation. + +Bean overrides create separate cached Spring contexts and can affect authentication in browserless test suites. +See <<{articles}/flow/testing/browserless/spring-security#application-context-isolation,Application Context Isolation>> for the integration behavior. + +For service-level tests that don't need the Vaadin context, an even simpler option is to construct the service directly with stub collaborators (for example `new ResourceService(repo, id -> 0L)`) instead of overriding a bean. These approaches avoid creating additional contexts through bean overrides. +==== + + +[role="since:com.vaadin:vaadin@V25.2"] +[#sharing-the-vaadin-environment-across-tests] +== Share the Vaadin Environment in Plain Java + +By default, the Vaadin environment -- the session, the UI, and all routes -- is created before every test method and torn down after. For classes with many tests that navigate to views sharing the same [classname]`MainLayout`, this setup cost can dominate the test runtime. + +To reuse a single Vaadin environment across all test methods in a class, register a static [classname]`BrowserlessClassExtension` with [annotationname]`@RegisterExtension`. The extension initializes the environment once before all tests and tears it down after all tests, sharing the same [classname]`UI` instance across every method. Use the extension instance for navigation and queries. +This option applies to plain Java tests; keep the framework-specific base class for Spring and Quarkus tests. + +.Shared Environment Example +[source,java] +---- +@ViewPackages(classes = HelloWorldView.class) +class HelloWorldViewTest { + + @RegisterExtension + static BrowserlessClassExtension extension = new BrowserlessClassExtension(); + + @BeforeAll + static void setup() { + extension.navigate(HelloWorldView.class); + } + + @Test + void name_isInitiallyEmpty() { + Assertions.assertEquals("", extension.find(TextField.class) + .withLabel("Your name").single().getValue()); + } + + @Test + void greetingButton_isEnabled() { + Assertions.assertTrue(extension.find(Button.class) + .withText("Say hello").single().isEnabled()); + } +} +---- + +These read-only tests use the `HelloWorldView` from <>. +They can run in either order because neither changes the shared UI. + +[WARNING] +With a shared environment, state leaks between tests. Tests that mutate state must reset it explicitly. +Re-navigating can reset view-local state, but does not reset session or application-owned data. Prefer a shared environment for read-only or independent interactions; stick with the default per-method lifecycle when tests mutate shared state in conflicting ways. + +[NOTE] +The base test classes -- [classname]`BrowserlessTest`, [classname]`SpringBrowserlessTest`, and [classname]`QuarkusBrowserlessTest` -- always reinitialize the Vaadin environment before each test method. Annotating them with [annotationname]`@TestInstance(PER_CLASS)` therefore does not share the Vaadin environment, although it can still be useful for sharing other per-class state. Use a static [classname]`BrowserlessClassExtension` as shown above to share the Vaadin environment. + +== Compare Execution Time + +Run the same suite before and after each change. +Keep the default per-method environment unless sharing it provides a useful improvement and the tests reset their state reliably. +JUnit extensions are for plain Java tests; they do not replace the Spring or Quarkus base classes. +See <<{articles}/flow/testing/browserless/extensions#,JUnit 6 Extensions>> for lifecycle contracts. + + +[discussion-id]`A3B7E2F1-5D89-4C6A-9E12-7F4A8B3C6D50` diff --git a/articles/building-apps/testing/browserless/test-custom-components.adoc b/articles/building-apps/testing/browserless/test-custom-components.adoc new file mode 100644 index 0000000000..4df93c6d36 --- /dev/null +++ b/articles/building-apps/testing/browserless/test-custom-components.adoc @@ -0,0 +1,192 @@ +--- +title: Test a Custom Component +page-title: Test a Custom Component | Vaadin +description: Test a component without a route, add a reusable component tester, and encapsulate interactions in a custom locator. +meta-description: Test a component without a route, add a reusable component tester, and encapsulate interactions in a custom locator. +order: 50 +--- + + += Test a Custom Component + +Add the dependencies from <<{articles}/building-apps/testing/browserless/setup-without-spring#,the plain Java setup guide>> before using the standalone component API. +This guide constructs components directly without a dependency injection container. +The standalone factories do not apply the CDI setup or resolve `@Inject` dependencies. +For a CDI-managed component, test it in a registered CDI view using <>. + +The examples use illustrative application components: replace `MyForm` with your form and place the `PhoneNumberField` below inside `PersonFormView`. + +== Testing a Component Without a View + +A single component -- for example, a form or a custom field -- can be tested in isolation, without wrapping it in an [annotationname]`@Route` view. [methodname]`BrowserlessUIContext.forComponent()` builds a self-contained, route-free test environment, attaches the component to a window's [classname]`UI`, and tears everything down when the window closes, so a single try-with-resources is enough: + +[source,java] +---- +try (var window = BrowserlessUIContext.forComponent(new MyForm())) { + window.findTextField().withLabel("Name").setValue("Ada"); + Assertions.assertEquals("Ada", + window.findTextField().withLabel("Name").component().getValue()); +} +---- + +The returned window is a [classname]`BrowserlessUIContext`, so the full testing DSL is available: [methodname]`find()`, [methodname]`test()`, and the typed locator entry points such as [methodname]`findButton()`. See <<{articles}/flow/testing/browserless/multi-user#, Multi-User and Multi-Window Testing>> for the context API. + +If the component's constructor needs [methodname]`UI.getCurrent()` or the session, pass a factory instead: [methodname]`BrowserlessUIContext.forComponent(MyForm::new)`. The factory runs after the Vaadin thread-locals are installed, so the constructor observes the live environment. + +For tests that need the same standalone component in several windows or for several users, use [methodname]`BrowserlessApplicationContext.forComponent(Supplier)`. It returns the application context, and every window created from it gets a fresh component instance from the factory: + +[source,java] +---- +try (var app = BrowserlessApplicationContext.forComponent(MyForm::new)) { + var w1 = app.newUser().newWindow(); + var w2 = app.newUser().newWindow(); + + w1.findTextField().withLabel("Name").setValue("Ada"); + // w2 holds its own MyForm instance + Assertions.assertEquals("", w2.findTextField().withLabel("Name") + .component().getValue()); +} +---- + + +== Building Custom Testers + +When you create custom components, you can build testers for them too. Custom testers extend [classname]`ComponentTester` and use the [annotationname]`@Tests` annotation to declare which component they test. + + +=== Defining a Custom Tester + +Place this tester in the same package as `PersonFormView` so it can access the child fields. + +[source,java] +---- +// Tests defines the components this tester should be used for automatically +@Tests(PersonFormView.PhoneNumberField.class) +public class PhoneNumberFieldTester extends ComponentTester { + // Other testers can be used inside the custom tester + final ComboBoxTester, String> combo_; + final TextFieldTester number_; + + public PhoneNumberFieldTester(PersonFormView.PhoneNumberField component) { + super(component); + combo_ = new ComboBoxTester<>( + getComponent().countryCode); + number_ = new TextFieldTester<>(getComponent().number); + } + + public List getCountryCodes() { + return combo_.getSuggestionItems(); + } + + public void setCountryCode(String code) { + ensureComponentIsUsable(); + if (!getCountryCodes().contains(code)) { + throw new IllegalArgumentException("Given code isn't available for selection"); + } + combo_.selectItem(code); + } + + public void setNumber(String number) { + ensureComponentIsUsable(); + number_.setValue(number); + } + + public String getValue() { + return getComponent().getValue(); + } + +} +---- + +.`PhoneNumberField` Nested in `PersonFormView` +[source,java] +---- +public static class PhoneNumberField extends CustomField { + final ComboBox countryCode = new ComboBox<>(); + final TextField number = new TextField(); + + public PhoneNumberField() { + countryCode.setItems("+1", "+358"); + add(countryCode, number); + countryCode.addValueChangeListener(event -> updateValue()); + number.addValueChangeListener(event -> updateValue()); + } + + @Override + protected String generateModelValue() { + return (countryCode.getValue() == null ? "" : countryCode.getValue()) + + " " + number.getValue(); + } + + @Override + protected void setPresentationValue(String value) { + if (value == null || value.isBlank()) { + countryCode.clear(); + number.clear(); + return; + } + String[] parts = value.split(" ", 2); + countryCode.setValue(parts[0]); + number.setValue(parts.length > 1 ? parts[1] : ""); + } +} +---- + +Custom testers can use other testers internally, as shown above with `ComboBoxTester` and `TextFieldTester`. + +.Generic Components +[TIP] +The [annotationname]`@Tests` annotation also has an [methodname]`fqn` attribute that accepts fully qualified class names as strings. Use this when the component type uses generics that prevent it from being passed as a class literal: +`@Tests(fqn = "com.example.MyField")`. + + +=== Registering Custom Testers + +Keep your tester in an application-owned package and annotate the test class with [annotationname]`@ComponentTesterPackages`: + +[source,java] +---- +@ComponentTesterPackages("com.example.application.views.personform") +class PersonFormViewTest extends BrowserlessTest { +} +---- + +== Custom Locators + +The following example assumes a `PersonForm` with fields identified by `pf-name` and `pf-email`, and a button identified by `pf-submit`. +Use it inside a test window containing that form. + +For composites, page objects, or domain-specific widgets, subclass [classname]`Locator` with the recursive self-type so filter steps stay fluent, and expose the actions you want the test to see. Scope inner queries with [methodname]`inside(this)` so they only match descendants of the resolved composite: + +[source,java] +---- +public class PersonFormLocator + extends Locator { + + 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(); + } +} +---- + +Tests reach the custom locator through [methodname]`find(Supplier<L>)`: + +[source,java] +---- +window.find(PersonFormLocator::new) + .fillIn("Ada", "ada@example.com") + .submit(); +---- + + +[discussion-id]`93145B04-239A-434F-A5B8-C95EAC71013B` diff --git a/articles/building-apps/testing/browserless/test-multiple-users.adoc b/articles/building-apps/testing/browserless/test-multiple-users.adoc new file mode 100644 index 0000000000..5b9c22b8c6 --- /dev/null +++ b/articles/building-apps/testing/browserless/test-multiple-users.adoc @@ -0,0 +1,283 @@ +--- +title: Test Multiple Users and Windows +page-title: Test Multiple Users and Windows | Vaadin +description: Verify shared state, independent windows, and authentication isolation across several browserless user sessions. +meta-description: Verify shared state, independent windows, and authentication isolation across several browserless user sessions. +order: 80 +--- + + += Test Multiple Users and Windows + +Use this guide when the behavior under test depends on more than one user or window. +The examples cover plain Java, Spring, and Quarkus application-context factories. +They do not provide the Weld/CDI wiring from <>. +That single-user recipe does not establish separate CDI session contexts for multiple browserless users; the factories below are not a drop-in replacement for its base class. +Complete the <<{articles}/building-apps/testing/browserless/setup-spring-boot#,Spring>>, <<{articles}/building-apps/testing/browserless/setup-without-spring#,plain Java>>, or <<{articles}/building-apps/testing/browserless/setup-quarkus#,Quarkus>> setup first. +The examples are patterns to adapt: `CartView`, `CheckoutView`, `SharedCounterView`, and `ChatView` represent your application views. +For security scenarios, reuse the access-control configuration from <<{articles}/building-apps/testing/browserless/test-view-access#,Test View Access Control>>. + +== Setting Up the Application Context + +The application context is built once per test, typically in [annotationname]`@BeforeEach`, and closed in [annotationname]`@AfterEach`. Use try-with-resources or call [methodname]`close()` explicitly: closing the application context cascades to every user and window it created. + +[methodname]`create()` accepts the packages that contain [annotationname]`@Route`-annotated views, either as package names or as classes whose packages should be scanned. Passing classes plays well with IDE refactoring and is the preferred form. + +.Plain Java +[source,java] +---- +try (var app = BrowserlessApplicationContext.create(CartView.class)) { + var user = app.newUser(); + var window = user.newWindow(); + window.navigate(CartView.class); + // assertions... +} +---- + +For Spring and Quarkus, dedicated factories pre-wire the framework-specific servlet and lookup initialization: + +.Spring +[source,java] +---- +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ShopTestConfig.class) +class CartViewMultiUserTest { + + @Autowired + private ApplicationContext applicationContext; + + private BrowserlessApplicationContext app; + + @BeforeEach + void setUp() { + app = SpringBrowserlessApplicationContext.create(applicationContext, + CartView.class); + } + + @AfterEach + void tearDown() { + app.close(); + } +} +---- + +.Quarkus +[source,java] +---- +@QuarkusTest +class CartViewMultiUserTest { + + private BrowserlessApplicationContext app; + + @BeforeEach + void setUp() { + app = QuarkusBrowserlessApplicationContext.create(CartView.class); + } + + @AfterEach + void tearDown() { + app.close(); + } +} +---- + + +== Creating Users and Windows + +[methodname]`newUser()` returns a fresh [classname]`BrowserlessUserContext` with its own [classname]`VaadinSession`. [methodname]`newWindow()` creates a new [classname]`UI` for that user. Different users have independent sessions; different windows of the same user share a session but have independent [classname]`UI` instances. + +.Two Users, Independent Sessions +[source,java] +---- +var alice = app.newUser(); +var aliceWindow = alice.newWindow(); + +var bob = app.newUser(); +var bobWindow = bob.newWindow(); + +Assertions.assertNotSame(alice.getSession(), bob.getSession()); +Assertions.assertNotSame(aliceWindow.getUI(), bobWindow.getUI()); +---- + +Use the window instance to navigate, find components, and perform actions. + +.Two Users Sharing Application-Level State +[source,java] +---- +var w1 = app.newUser().newWindow(); +w1.navigate(SharedCounterView.class); + +var w2 = app.newUser().newWindow(); +w2.navigate(SharedCounterView.class); + +// w1 mutates a shared static counter +w1.findButton().withText("Increment").click(); +Assertions.assertEquals("Count: 1", w1.findParagraph().getText()); + +// w2 still shows its own UI state until it refreshes +Assertions.assertEquals("Count: 0", w2.findParagraph().getText()); + +w2.findButton().withText("Refresh").click(); +Assertions.assertEquals("Count: 1", w2.findParagraph().getText()); +---- + +.Same User, Two Windows, Independent UI State +[source,java] +---- +var user = app.newUser(); +var w1 = user.newWindow(); +var w2 = user.newWindow(); + +w1.navigate(CartView.class); +w2.navigate(CheckoutView.class); + +// Each window holds its own current view +Assertions.assertInstanceOf(CartView.class, w1.getCurrentView()); +Assertions.assertInstanceOf(CheckoutView.class, w2.getCurrentView()); + +// Session is the same; UIs are not +Assertions.assertSame(user.getSession(), w1.getUI().getSession()); +Assertions.assertNotSame(w1.getUI(), w2.getUI()); +---- + + +[#signals] +== Signals + +The application context registers the test [classname]`SignalEnvironment`, so signal effects run deterministically instead of on a background thread pool. For single-user examples, see <>. When one window mutates a signal that other windows observe -- the typical pattern for collaborative features built on shared signals -- call [methodname]`runPendingSignalsTasks()` to process the pending effects before asserting on the observing window: + +.Two Users Observing a Shared Signal +[source,java] +---- +var w1 = app.newUser().newWindow(); +w1.navigate(ChatView.class); + +var w2 = app.newUser().newWindow(); +w2.navigate(ChatView.class); + +// w1 updates a shared signal that both views are bound to +w1.findTextField().withLabel("Message").setValue("Hello!"); +w1.findButton().withText("Send").click(); + +// Process the pending signal effects, then assert on the other window +w2.runPendingSignalsTasks(); +Assertions.assertEquals("Hello!", w2.findParagraph().getText()); +---- + +For background updates and write confirmation, see <<{articles}/building-apps/testing/browserless/test-signals#,Test Signal-Based Views>>. + + +== Authenticated Users with Spring Security + +Create a secured application context, then interleave actions from an administrator and an anonymous user. +Assert that the anonymous user is redirected while the administrator retains access. + +.Multi-User Security Isolation +[source,java] +---- +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = SecurityTestConfig.class) +class MultiUserSecurityTest { + + @Autowired + private ApplicationContext applicationContext; + + private SecuredBrowserlessApplicationContext app; + + @BeforeEach + void setUp() { + app = SpringBrowserlessApplicationContext.createSecured( + applicationContext, ProtectedView.class); + } + + @AfterEach + void tearDown() { + app.close(); + } + + @Test + void switchingUsers_securityContextFollowsActiveWindow() { + var admin = app.newUser("john", "ADMIN").newWindow(); + var anon = app.newUser().newWindow(); + + admin.navigate(ProtectedView.class); + Assertions.assertInstanceOf(ProtectedView.class, + admin.getCurrentView()); + + // Switching to the anonymous user restores their (empty) context; + // the protected view redirects to login. + Assertions.assertThrows(IllegalArgumentException.class, + () -> anon.navigate(ProtectedView.class)); + Assertions.assertInstanceOf(LoginView.class, anon.getCurrentView()); + + // Switching back restores admin's authentication. + admin.navigate(ProtectedView.class); + Assertions.assertInstanceOf(ProtectedView.class, + admin.getCurrentView()); + } +} +---- + +For custom credentials, anonymous users, and logout behavior, see <<{articles}/flow/testing/browserless/multi-user#authenticated-users-with-spring-security,Spring Security Contexts>>. + + +== Authenticated Users with Quarkus Security + +The Quarkus factory follows the same pattern with [classname]`SecurityIdentity` as the credential type: + +.Quarkus Multi-User Test +[source,java] +---- +@QuarkusTest +@TestProfile(SecurityTestConfig.class) +class MultiUserSecurityTest { + + private SecuredBrowserlessApplicationContext app; + + @BeforeEach + void setUp() { + app = QuarkusBrowserlessApplicationContext + .createSecured(ProtectedView.class); + } + + @AfterEach + void tearDown() { + app.close(); + } + + @Test + void authenticatedUser_byUsernameAndRoles_seesProtectedView() { + var window = app.newUser("john", "USER").newWindow(); + + window.navigate(ProtectedView.class); + Assertions.assertInstanceOf(ProtectedView.class, + window.getCurrentView()); + } + + @Test + void authenticatedUser_byIdentity_seesProtectedView() { + SecurityIdentity identity = QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal("john")) + .addRoles(Set.of("USER")) + .setAnonymous(false) + .build(); + + var window = app.newUser(identity).newWindow(); + + window.navigate(ProtectedView.class); + Assertions.assertInstanceOf(ProtectedView.class, + window.getCurrentView()); + } +} +---- + +As with the Spring factory, [methodname]`newUser()` without arguments creates an anonymous user, and cross-user window switches save and restore the active [classname]`SecurityIdentity` automatically. + +== Keep Tests Independent + +Close the application context after each test and reset application-owned shared data. +Create and use each context on the same test thread. +See <<{articles}/flow/testing/browserless/multi-user#pitfalls-and-guarantees,context guarantees>> for thread affinity, direct API access, and security-state ownership. + + +[discussion-id]`B92B85CC-5CFD-4B22-8BA6-B61CC1903D7B` diff --git a/articles/building-apps/testing/browserless/test-signals.adoc b/articles/building-apps/testing/browserless/test-signals.adoc new file mode 100644 index 0000000000..3ceb493f7d --- /dev/null +++ b/articles/building-apps/testing/browserless/test-signals.adoc @@ -0,0 +1,273 @@ +--- +title: Test Signal-Based Views +page-title: Test Signal-Based Views | Vaadin +description: Assert changes to signal bindings, process background updates, and verify shared-signal write confirmation. +meta-description: Assert changes to signal bindings, process background updates, and verify shared-signal write confirmation. +order: 70 +--- + + += Test Signal-Based Views + +[#synchronous] +== Assert Changes After a User Action + +Start with synchronous interactions: trigger an action, then assert the resulting component state. +The following examples use `BrowserlessTest` with <<{articles}/building-apps/testing/browserless/setup-without-spring#,the plain Java setup>>. +For Java EE/CDI, extend <> instead, add the view and concrete CDI dependencies to its test archive, and register the route in its setup hook. +The CDI setup calls `initSignalsSupport()` explicitly; a custom override that omits it does not install the signal environment described here. +For Spring Boot or Quarkus, retain the framework-specific base class and annotations. + + +=== Test a Computed Label + +This view holds a count in a [classname]`ValueSignal`, derives a label string with [methodname]`Signal.computed()`, and binds it to a [classname]`Span` with [methodname]`bindText()`: + +[source,java] +---- +@Route("counter-signal") +public class CounterSignalView extends Div { + final ValueSignal count = new ValueSignal<>(0); + final Span label = new Span(); + final NativeButton increment = + new NativeButton("Increment", e -> count.update(c -> c + 1)); + + public CounterSignalView() { + label.bindText(Signal.computed(() -> "Count: " + count.get())); + add(label, increment); + } +} +---- + +The test clicks the button and asserts the label right away: + +[source,java] +---- +@ViewPackages(classes = CounterSignalView.class) +class CounterSignalTest extends BrowserlessTest { + + @Test + void clickIncrement_labelUpdatesSynchronously() { + var view = navigate(CounterSignalView.class); + Assertions.assertEquals("Count: 0", test(view.label).getText()); + + test(view.increment).click(); + + // No waiting and no runPendingSignalsTasks() — the computed signal + // and bindText effect already ran on the test thread. + Assertions.assertEquals("Count: 1", test(view.label).getText()); + } +} +---- + + +=== Test List Changes + +Structural changes propagate the same way. This view binds a layout's children to a [classname]`ListSignal`, rendering one [classname]`Span` per entry. Clicking the button inserts an entry: + +[source,java] +---- +@Route("tags-signal") +public class TagListView extends Div { + final ListSignal tags = new ListSignal<>(); + final VerticalLayout list = new VerticalLayout(); + final NativeButton addButton = + new NativeButton("Add tag", e -> tags.insertLast("tag")); + + public TagListView() { + list.bindChildren(tags, entry -> new Span(entry.peek())); + add(list, addButton); + } +} +---- + +The inserted child is present as soon as the click returns: + +[source,java] +---- +@ViewPackages(classes = TagListView.class) +class TagListTest extends BrowserlessTest { + + @Test + void addTag_childAppearsSynchronously() { + var view = navigate(TagListView.class); + Assertions.assertEquals(0, view.list.getComponentCount()); + + test(view.addButton).click(); + + // The bindChildren effect rebuilt the list synchronously. + Assertions.assertEquals(1, view.list.getComponentCount()); + } +} +---- + +Both tests pass without flushing anything because the mutation happens on the test thread — which is the UI thread — while the components are attached. The effect runs inline, as part of the [methodname]`set()`, [methodname]`update()`, or [methodname]`insertLast()` call. + + +== What You Can Bind + +Most signal-driven UI is wired up with the [methodname]`bind*` family rather than explicit effects. From a test's perspective, each binding is a different property to assert on after a signal changes: + +- [methodname]`bindText(signal)` — assert with [methodname]`test(component).getText()` or [methodname]`component.getText()`. +- [methodname]`bindVisible(signal)` / [methodname]`bindEnabled(signal)` — assert visibility or enabled state; a tester's [methodname]`isUsable()` reflects both. +- [methodname]`bindValue(signal, setter)` — two-way. Mutate the signal and assert the field value, or set the field value through its tester and assert the signal with [methodname]`signal.peek()`. +- [methodname]`bindChildren(listSignal, factory)` — assert the rendered child count or the individual entries. + +This page focuses on testing. For the full binding API, see <<{articles}/flow/ui-state/building-ui#, Component Bindings>> and <<{articles}/flow/ui-state/element-bindings#, Element Bindings>>. + + +== Custom Effects + +A side effect created with [methodname]`Signal.effect()` runs under the same test environment as the bindings, so it also executes synchronously when a dependency changes on the test thread. Use this to assert behavior that isn't a simple property binding — for example, showing a notification. + +This view writes the field value into a [classname]`ValueSignal` and registers an effect that opens a [classname]`Notification` whenever the amount crosses a threshold: + +[source,java] +---- +@Route("threshold") +public class ThresholdView extends Div { + final ValueSignal amountSignal = new ValueSignal<>(0); + final TextField amount = new TextField(); + + public ThresholdView() { + amount.bindValue( + amountSignal.map(String::valueOf), + v -> amountSignal.set(Integer.parseInt(v))); + + // The effect re-runs whenever amountSignal changes. + Signal.effect(this, () -> { + if (amountSignal.get() > 100) { + Notification.show("Over limit"); + } + }); + + add(amount); + } +} +---- + +The test changes the field and asserts the notification right away: + +[source,java] +---- +@ViewPackages(classes = ThresholdView.class) +class ThresholdTest extends BrowserlessTest { + + @Test + void valueExceedsLimit_notificationShownSynchronously() { + var view = navigate(ThresholdView.class); + + test(view.amount).setValue("150"); + + Assertions.assertEquals("Over limit", + test(find(Notification.class).single()).getText()); + } +} +---- + + +[#background-threads] +== Updates from Background Threads + +A signal mutated *off* the UI thread — from a service callback, a [classname]`CompletableFuture`, or another session — doesn't propagate synchronously. The test [classname]`SignalEnvironment` queues the effect instead of running it inline. Call [methodname]`runPendingSignalsTasks()` to drain the queue before asserting: + +[source,java] +---- +// The view starts asynchronous work that mutates a signal on a background thread +test(view.startBackgroundWork).click(); + +// Drain the queued signal effects, then assert +runPendingSignalsTasks(); +Assertions.assertEquals("Done", test(view.status).getText()); +---- + +See <<{articles}/flow/testing/browserless/testing-signals#background-threads,signal task processing>> for timeout and return-value semantics. + + +=== Shared Signals + +<<{articles}/flow/ui-state/shared-signals#, Shared signals>> -- [classname]`SharedValueSignal`, [classname]`SharedNumberSignal`, [classname]`SharedListSignal`, and the other shared types -- are the most common source of background updates in a test. A change made in one session is propagated to every other session that observes the signal, and that propagation is inherently asynchronous: the observing side sees it through a queued effect rather than inline. + +As a result, a change that an observer should react to needs the same treatment as any other off-thread mutation. After triggering the change, call [methodname]`runPendingSignalsTasks()` before asserting on the observing side. A change made and observed on the same test thread -- such as mutating a shared signal and asserting a binding on the same view -- still propagates synchronously and needs no flush. For tests that drive several sessions or windows observing one shared signal, see <<{articles}/flow/testing/browserless/multi-user#signals, Signals in Multi-User Tests>>. + + +[#shared-signal-writes] +=== Confirming a Write + +A write to a shared signal returns a <<{articles}/flow/ui-state/transactions#operation-results, [classname]`SignalOperation`>> that completes once the underlying signal tree has confirmed the command. The write itself is applied optimistically, so the new value is visible through [methodname]`peek()` as soon as the call returns -- while the confirmation travels through the same queue as the effects. + +This view inserts a ticket into a [classname]`SharedListSignal` and updates a status label when the write is confirmed. The result callback is delivered in the context that started the operation, so it can touch components directly: + +[source,java] +---- +@Route("tickets") +public class TicketView extends Div { + final SharedListSignal tickets = + new SharedListSignal<>(String.class); + final TextField title = new TextField("Title"); + final Span status = new Span(); + final NativeButton submit = new NativeButton("Submit"); + + public TicketView() { + submit.addClickListener(e -> submitTicket(title.getValue())); + add(title, submit, status); + } + + InsertOperation> submitTicket(String title) { + status.setText("Saving..."); + + var operation = tickets.insertLast(title); + operation.result().thenAccept(result -> status.setText( + result.successful() ? "Ticket created" : "Save failed")); + return operation; + } +} +---- + +The entry is in the list right after the click, but the status label still reads [code]`Saving...` -- the callback runs only once the queued confirmation task has been executed: + +[source,java] +---- +@ViewPackages(classes = TicketView.class) +class TicketViewTest extends BrowserlessTest { + + @Test + void submitTicket_statusUpdatesWhenWriteIsConfirmed() { + var view = navigate(TicketView.class); + test(view.title).setValue("Printer is jammed"); + + test(view.submit).click(); + + // Inserted optimistically, but not confirmed yet. + Assertions.assertEquals(1, view.tickets.peek().size()); + Assertions.assertEquals("Saving...", test(view.status).getText()); + + runPendingSignalsTasks(); + + Assertions.assertEquals("Ticket created", test(view.status).getText()); + } +} +---- + +A test that gets hold of the operation itself -- because the code under test returns it, as [methodname]`submitTicket()` does -- can assert on the confirmation directly instead of going through the UI: + +[source,java] +---- +@Test +void submitTicket_operationConfirmedAfterDrainingQueue() { + var view = navigate(TicketView.class); + + var operation = view.submitTicket("Printer is jammed"); + Assertions.assertFalse(operation.result().isDone()); + + runPendingSignalsTasks(); + + Assertions.assertTrue(operation.result().join().successful()); +} +---- + +[WARNING] +Don't block on the operation before draining the queue. A call such as [code]`operation.result().get(5, TimeUnit.SECONDS)` always times out, because the confirmation task can only run on the very thread that's blocked waiting for it. A timeout there means the queue hasn't been drained -- not that the write was lost. + + +[discussion-id]`078DFEEA-BDF0-46A2-BD55-3ECD037448E7` diff --git a/articles/building-apps/testing/browserless/test-user-interactions.adoc b/articles/building-apps/testing/browserless/test-user-interactions.adoc new file mode 100644 index 0000000000..88ffdb1d4f --- /dev/null +++ b/articles/building-apps/testing/browserless/test-user-interactions.adoc @@ -0,0 +1,126 @@ +--- +title: Test User Interactions +page-title: Test User Interactions | Vaadin +description: Find components, simulate navigation and keyboard shortcuts, and test menu actions in browserless tests. +meta-description: Find components, simulate navigation and keyboard shortcuts, and test menu actions in browserless tests. +order: 40 +--- + + += Test User Interactions + +Start with the <<./#choose-your-framework,setup for your application framework>>. +The interaction methods in this guide are shared, but retain your framework-specific test base class and initialization. +For Java EE/CDI, extend `AbstractCdiViewTest` and register each tested view and its dependencies as described in <>. +Each example below is a pattern to adapt to your view; it belongs inside a test method unless shown otherwise. + +== Find the Component to Exercise + +Use a label or visible text when it identifies the component unambiguously. +Scope the query when the same field appears more than once: + +[source,java] +---- +TextField name = findInView(TextField.class) + .withLabel("First name").single(); +test(name).setValue("Ada"); +Assertions.assertEquals("Ada", name.getValue()); +---- + +Use `find(Type.class, container)` to restrict a query to a particular container. +A component inside a Grid renderer may need to be obtained through `test(grid).getCellComponent(...)` before it can be exercised. +An empty top-level query does not necessarily mean that the application failed to create the component. +See <<{articles}/flow/testing/browserless/component-query#components-a-query-cannot-find,Query Boundaries>> and <<{articles}/flow/testing/browserless/component-query#,Component Queries>> for the full behavior. + +[#test-ids] +== Test IDs + +Use [methodname]`Component.setTestId()` to assign a stable identifier to a component for use in tests. This sets the `data-testid` HTML attribute on the component's element, which can also be used by browser-based testing frameworks: + +[source,java] +---- +Button submitButton = new Button("Submit"); +submitButton.setTestId("submit-button"); + +// Later, retrieve the test ID +String testId = submitButton.getTestId(); // "submit-button" +---- + +Use a test ID when a label or visible text would be ambiguous or would change with translations. +Keep the identifier independent of styling and layout. + + +Browserless tests can use the same identifier: the [since:com.vaadin:vaadin@V25.2]#`testId()` terminal operator and the `withTestId()` filter# look up components by their test ID: + +[source,java] +---- +Button submit = find(Button.class).testId("submit-button"); +---- + +See <<{articles}/flow/testing/browserless/component-query#test-ids, Querying Components>> for details. + +== Use Locators for Repeated Interactions + +In a base-class test, implement `Locators` to combine lookup and interaction. +The class below uses the plain Java setup. +In a CDI test, add `implements Locators` to your `AbstractCdiViewTest` subclass instead; retain its CDI configuration. + +[source,java] +---- +class CartViewTest extends BrowserlessTest implements Locators { + @Test + void addItem_increasesCartSize() { + navigate(CartView.class); + findButton().withText("Add to cart").click(); + Assertions.assertEquals("1 item", + findSpan().withId("cart-size").getText()); + } +} +---- + +This example assumes that the cart view updates the span after adding an item. +For locator reuse and invalidation rules, see <<{articles}/flow/testing/browserless/locators#resolution,Locator Resolution>>. + +== Test Navigation and Shortcuts + +Navigate by location when the outcome may be a redirect, and pass the expected view class: + +[source,java] +---- +navigate("protected", LoginView.class); +---- + +A location can also carry a query string and fragment; for example, [since:com.vaadin:vaadin@V25.3]#`navigate("orders/123?tab=history#details", OrderView.class)`#. +Assert the state selected by those parameters after navigation. + +For route parameters and other navigation forms, see <<{articles}/flow/testing/browserless/test-environment#navigating-to-views,Navigation>>. + +To exercise a save shortcut, invoke it after changing the form and then assert the same outcome as clicking Save: + +[source,java] +---- +fireShortcut(Key.KEY_S, KeyModifier.CONTROL); +// Assert the saved state or the confirmation shown by your view. +---- + +== Test Menu Actions + +Use a menu tester to reach overlay content. For a view exposing a `contextMenu` field: + +[source,java] +---- +var view = navigate(EditorView.class); +var menu = test(view.contextMenu); +menu.open(); +menu.clickItem("Bold"); +Assertions.assertTrue(menu.isItemChecked("Bold")); +---- + +For nested actions, pass a text path such as `clickItem("Share", "Email")`. +For a menu containing custom components, call `test(menuComponent).find(...)`. +Open the menu before clicking items or reading their state. +Only the menu tester's `find()` query works while it is closed; returned components remain detached until it opens. +See <<{articles}/flow/testing/browserless/overlay-components#,Overlay Component Testers>> for indexing and attachment semantics. + + +[discussion-id]`BDC6250E-E9D6-44DD-9B67-777C0F7E98AA` diff --git a/articles/building-apps/testing/browserless/test-view-access.adoc b/articles/building-apps/testing/browserless/test-view-access.adoc new file mode 100644 index 0000000000..2917f9f70a --- /dev/null +++ b/articles/building-apps/testing/browserless/test-view-access.adoc @@ -0,0 +1,318 @@ +--- +title: Test View Access Control +page-title: Test View Access Control | Vaadin +description: Verify anonymous, authorized, and unauthorized navigation in Spring and Quarkus browserless tests. +meta-description: Verify anonymous, authorized, and unauthorized navigation in Spring and Quarkus browserless tests. +order: 60 +--- + + += Test View Access Control + +This page covers Spring Security and Quarkus Security in separate sections. +For Java EE/CDI, use <> and your application's CDI access-control beans and login flow; the security annotations below do not configure Weld. + +First configure <<{articles}/building-apps/security/protect-views#,view protection>> in your application. +These examples assume a public default route, a login view, and views restricted to specific roles. +Adapt the route names and roles to your application. + +[#spring] +== Spring Security + +Start with <<{articles}/building-apps/testing/browserless/setup-spring-boot#,the Spring Boot test setup>>. + +=== Set Up View Access Control + +To apply view access control, Vaadin requires a [classname]`NavigationAccessControl` to be registered as a [classname]`BeforeEnterListener` for the UI. For [annotationname]`@SpringBootTest` annotated tests, the checker is created and configured automatically. However, when testing with a restricted `ApplicationContext`, you may want to perform the setup yourself in a [classname]`Configuration` class by providing a [classname]`VaadinServiceInitListener` that executes this step. + +.Set Up NavigationAccessControl for Plain Spring Project +[source,java] +---- +@Configuration +class TestViewSecurityConfig { + + @Bean + VaadinServiceInitListener setupViewSecurityScenario() { + SpringNavigationAccessControl accessControl = new SpringNavigationAccessControl(); + accessControl.setLoginView(LoginView.class); + return event -> { + event.getSource().addUIInitListener(uiEvent -> { + uiEvent.getUI().addBeforeEnterListener(accessControl); + }); + }; + } +} +---- + +If you're using the Vaadin Spring Add-On, you can instead import the out-of-the-box [classname]`NavigationAccessControlInitializer`. It requires only that you define a [classname]`NavigationAccessControl` bean. + +.Set Up NavigationAccessControl with Vaadin Spring Add-On +[source,java] +---- +@Configuration +@Import({NavigationAccessControlInitializer.class}) +class TestViewSecurityConfig { + + @Bean + NavigationAccessControl navigationAccessControl() { + return new SpringNavigationAccessControl(); + } +} +---- + +=== Testing with Spring Security Annotations + +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. + +To use Spring Security test annotations, first make sure the dependency is added to the project. + +[source,xml] +---- + + org.springframework.security + spring-security-test + test + +---- + +[CAUTION] +Overriding beans with [annotationname]`@MockitoBean` or [annotationname]`@MockBean` makes Spring cache a separate application context for that test class. In a multi-class run, this can prevent the simulated user from being applied during navigation, causing protected views to redirect unexpectedly to the login view -- sometimes in *other* test classes. If security navigation tests fail only when the whole suite runs, suspect a bean override elsewhere. See <<{articles}/building-apps/testing/browserless/speed-up-tests#using-a-reduced-application-context,Using a Reduced Application Context>> for a context-friendly way to replace services. + +Then extend [classname]`SpringBrowserlessTest` and annotate test methods to set up an authentication scenario. For the simplest use cases, use [annotationname]`@WithMockUser` or [annotationname]`@WithAnonymousUser`, providing the username and roles that should be granted. + +.Tests with Mock Users +[source,java] +---- +@SpringBootTest +public class ViewSecurityTest extends SpringBrowserlessTest { + + @Test + @WithAnonymousUser + void anonymousUser_protectedView_redirectToLogin() { + navigate("protected", LoginView.class); + } + + @Test + @WithAnonymousUser + void anonymousUser_publicView_signInLinkPresent() { + // public view is default page + Assertions.assertInstanceOf(PublicView.class, getCurrentView()); + + Anchor anchor = find(Anchor.class).withText("Sign in").single(); + Assertions.assertTrue( + test(anchor).isUsable(), + "Sign in link should be available for anonymous user"); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + void adminUser_adminView_viewShown() { + navigate(AdminRoleView.class); + + Assertions.assertTrue( + find(Avatar.class).single().isVisible(), + "Avatar should be visible for logged users"); + } +} +---- + +When custom User objects or complex grant rules should be used, provide a custom [classname]`UserDetailsService` and annotate the test method with [annotationname]`@WithUserDetails`. + +.Tests with Mock UserDetailsService +[source,java] +---- +@ContextConfiguration(classes = SecurityTestConfig.class) +class SpringUnitSecurityTest extends SpringBrowserlessTest { + + @Test + @WithUserDetails("admin") + void superuser_adminView_viewShown() { + navigate(AdminRoleView.class); + + Assertions.assertTrue( + find(Avatar.class).single().isVisible(), + "Avatar should be visible for logged users"); + } + + @Test + @WithUserDetails + void user_adminView_accessDenied() { + RouteNotFoundError errorView = navigate("admin-role", + RouteNotFoundError.class); + Assertions.assertTrue( + errorView.getElement().getChild(0).getOuterHTML() + .contains("Reason: Access denied"), + "Admin view should be accessible only by users with ADMIN role"); + } + + +} + +@Configuration +class SecurityTestConfig { + + @Bean + UserDetailsService mockUserDetailsService() { + + return new UserDetailsService() { + @Override + public UserDetails loadUserByUsername(String username) + throws UsernameNotFoundException { + if ("user".equals(username)) { + return new User(username, UUID.randomUUID().toString(), + List.of( + new SimpleGrantedAuthority("ROLE_DEV"), + new SimpleGrantedAuthority("ROLE_USER") + )); + } + if ("admin".equals(username)) { + return new User(username, UUID.randomUUID().toString(), + List.of( + new SimpleGrantedAuthority("ROLE_SUPERUSER"), + new SimpleGrantedAuthority("ROLE_ADMIN") + )); + } + throw new UsernameNotFoundException( + "User " + username + " not exists"); + } + }; + } +} +---- + +[#authentication-applied-during-a-test] +=== Navigate after Changing Authentication + +The default Spring Security test annotations establish authentication before initial navigation. +When a test changes authentication later, navigate again to apply access control to the new user. +For a root route that redirects anonymous users to `LoginView`, use this Spring test method: + +[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()); +} +---- + +The example uses Spring Security's `TestExecutionEvent` from `org.springframework.security.test.context.support`. +For a login-form test, perform the application's login action and then navigate to the protected view before asserting its contents. +See <<{articles}/flow/testing/browserless/environment-differences#authentication-applied-during-a-test,Authentication Timing>> for why reloading the current location does not replace this step. + +[#quarkus] +== Quarkus Security + +Start with <<{articles}/building-apps/testing/browserless/setup-quarkus#,the Quarkus test setup>>. + +=== Set Up View Access Control + +To apply view access control, Vaadin requires a [classname]`NavigationAccessControl` to be registered as a [classname]`BeforeEnterListener` for the UI. Currently, the Vaadin Quarkus plugin doesn't support automatic registration of the access control feature. To enable it for browserless testing, perform the setup in a [classname]`QuarkusTestProfile` class by providing an observer for the Vaadin [classname]`ServiceInitEvent` that executes this step. + +.NavigationAccessControl for Quarkus Project Test +[source,java] +---- +public class TestViewSecurityConfig implements QuarkusTestProfile { + + @Override + public String getConfigProfile() { + return "test-security"; // <1> + } + + @IfBuildProfile("test-security") // <1> + public static class NavigationAccessControlInitializer { + + public void serviceInit(@Observes ServiceInitEvent event) { // <2> + // @QuarkusTest starts the whole application, so we check + // the VaadinService type to enable access control only for + // browserless tests + if (event.getSource() instanceof MockQuarkusServletService) { // <3> + event.getSource().addUIInitListener(uiEvent -> { + // Customize the NavigationAccessControl as needed + NavigationAccessControl accessControl = new NavigationAccessControl(); + accessControl.setLoginView(LoginView.class); + + uiEvent.getUI().addBeforeEnterListener(accessControl); + }); + } + } + } +} +---- +<1> Sets the configuration profile to be used for the test. The class is annotated with [annotationname]`@IfBuildProfile` to make the observer only run it for tests that require this profile. +<2> Listens for Vaadin [classname]`ServiceInitEvent`. This is the same as implementing [classname]`VaadinServiceInitListener` and registering the class to be loaded by Java [classname]`ServiceLoader`. +<3> Checks that execution is started by the browserless test. This is required because [annotationname]`@QuarkusTest` causes the whole application to start when running the test. + +=== Quarkus Test Security Features + +When using [classname]`QuarkusBrowserlessTest`, if Quarkus Security is present on the classpath, the mock environment is instructed to fetch authentication details from Quarkus [classname]`SecurityIdentity`. + +With this support, you can use Quarkus [annotationname]`@TestSecurity` annotation to simulate different authentication scenarios with test method granularity. More information is available from the https://quarkus.io/guides/security-testing[Quarkus Security Testing documentation]. Authentication details are available before creating the UI instance and navigating to the default route. 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. + +To use Quarkus Security test annotations, first ensure the dependency is added to the project: + +[source,xml] +---- + + io.quarkus + quarkus-test-security + test + +---- + +Next, extend [classname]`QuarkusBrowserlessTest` and annotate test methods to set up an authentication scenario. For the simplest situations, use [annotationname]`@TestSecurity`, providing the username and roles that should be granted. + +.Tests with Mock Users +[source,java] +---- +@QuarkusTest +@TestProfile(TestViewSecurityConfig.class) // <1> +class ViewSecurityTest extends QuarkusBrowserlessTest { + + @Test + @TestSecurity(authorizationEnabled = false) // <2> + void anonymousUser_protectedView_redirectToLogin() { + navigate("protected", LoginView.class); + } + + @Test + @TestSecurity(authorizationEnabled = false) // <2> + void anonymousUser_publicView_signInLinkPresent() { + // public view is default page + Assertions.assertInstanceOf(PublicView.class, getCurrentView()); + + Anchor anchor = find(Anchor.class).withText("Sign in").single(); + Assertions.assertTrue( + test(anchor).isUsable(), + "Sign in link should be available for anonymous user"); + } + + @Test + @TestSecurity(user = "admin", roles = "ADMIN") // <2> + void adminUser_adminView_viewShown() { + navigate(AdminRoleView.class); + + Assertions.assertTrue( + find(Avatar.class).single().isVisible(), + "Avatar should be visible for logged users"); + } +} +---- +<1> Sets a profile to activate Vaadin access control feature. +<2> Uses Quarkus test security annotations. + +== Verify the Suite + +Run the security tests together with the rest of the suite using `mvn test`. +For concurrent users and security isolation, see <<{articles}/building-apps/testing/browserless/test-multiple-users#,Test Multiple Users and Windows>>. + + +[discussion-id]`011585C3-F724-4B8D-B0B2-A52595587AAA` diff --git a/articles/building-apps/testing/index.adoc b/articles/building-apps/testing/index.adoc new file mode 100644 index 0000000000..0c3c89214d --- /dev/null +++ b/articles/building-apps/testing/index.adoc @@ -0,0 +1,24 @@ +--- +title: Testing +page-title: Testing | Vaadin +description: Write tests for your Vaadin application, verify user interactions, and diagnose failures. +meta-description: Write tests for your Vaadin application, verify user interactions, and diagnose failures. +order: 45 +--- + + += Testing + +These guides show you how to test the server-side behavior of Flow applications using browserless tests. +Start by <>, then choose a guide for the behavior you need to verify. + +Browserless tests run against Java components without a browser. +Use browser-based tests when you need to verify rendering, client-side behavior, or real browser interactions. +See <<{articles}/flow/testing#comparison,the testing reference>> for a comparison of the approaches. + +== Guides + +section_outline::[] + + +[discussion-id]`3E1E2938-89FF-4EFD-9EE2-658369A06F3A` diff --git a/articles/flow/integrations/cdi/index.adoc b/articles/flow/integrations/cdi/index.adoc index 9fba44cf6c..45889826b2 100644 --- a/articles/flow/integrations/cdi/index.adoc +++ b/articles/flow/integrations/cdi/index.adoc @@ -79,3 +79,9 @@ See <<{articles}/flow/configuration/properties#,Configuration Properties>> for m == Limitations The Vaadin CDI add-on doesn't support Hilla, because Hilla requires the use of Spring. + + +== Browserless Testing + +Use <<{articles}/building-apps/testing/browserless/setup-cdi#,Weld-backed browserless tests>> to exercise CDI-managed Flow views without deploying the application. +The <<{articles}/flow/testing/browserless/cdi#,testing reference>> describes lifecycle ordering and the boundary between the CDI and Vaadin contexts. diff --git a/articles/flow/testing/browserless/cdi.adoc b/articles/flow/testing/browserless/cdi.adoc new file mode 100644 index 0000000000..1bfcf83894 --- /dev/null +++ b/articles/flow/testing/browserless/cdi.adoc @@ -0,0 +1,82 @@ +--- +title: CDI Test Integration +page-title: CDI Test Integration | Vaadin +description: Lifecycle ordering, CDI bean discovery, scopes, servlet integration, and route registration in Weld-based browserless tests. +meta-description: Lifecycle ordering, CDI bean discovery, scopes, servlet integration, and route registration in Weld-based browserless tests. +order: 42 +--- + + += CDI Test Integration + +A Java EE / Jakarta EE application using Vaadin CDI can run browserless tests with Weld in the test JVM. +This integration uses an application-owned subclass of `BrowserlessTest` and a `CdiVaadinServlet`. +It does not deploy an application server or create a Spring application context. + +For dependencies, complete test classes, and CDI alternatives, see <<{articles}/building-apps/testing/browserless/setup-cdi#,Set Up Browserless Tests with Java EE/CDI>>. + +== Environment Lifecycle + +`BrowserlessTest` initializes and cleans up Vaadin through JUnit `@BeforeEach` and `@AfterEach` methods. +JUnit executes extension setup callbacks before `@BeforeEach` methods, so Weld can supply its bean manager before Vaadin initialization. +An overridden `initVaadinEnvironment()` retains `@BeforeEach` to remain a lifecycle method. + +The CDI setup replaces default initialization with tester registration, `MockVaadin.setup()` using the CDI servlet, signal-environment initialization, and route registration. +It does not call `super.initVaadinEnvironment()`, which would create the default environment first. +The inherited cleanup tears down Vaadin and signal support before extension cleanup stops Weld. + +The documented recipe uses JUnit's default per-method test-instance lifecycle. +Weld owns its CDI contexts; Browserless Test owns the simulated Vaadin environment. +Changing the JUnit test-instance lifecycle or sharing contexts requires reassessing both lifecycles. +The `MockVaadin` integration uses an internal API, so custom setup code should be checked when upgrading Browserless Test. + +== CDI Discovery and Scope Activation + +Weld JUnit's automatic setup builds a synthetic bean archive from the test package and its sub-packages, including matching application classes on the runtime classpath. +`@AddBeanClasses` and `@AddPackages` extend that archive. +`BeanDiscoveryMode.ALL` allows discovery without a bean-defining annotation inside the archive; it does not discover every dependency on the classpath. +The `beans.xml` file in the deployed WAR does not configure this test archive. + +The bean graph includes views, layouts, concrete injected implementations, producers, and observers. +Weld cannot instantiate an interface listed in `@AddBeanClasses`; it needs a concrete managed bean. +Test replacements use CDI alternatives and qualifiers; merely adding another ordinary implementation can make injection ambiguous. + +`@ActivateScopes(SessionScoped.class)` activates a CDI session context for beans that require it. +This is separate from creating a mocked `VaadinSession`. +The single-user Weld recipe does not establish a mapping between multiple browserless user contexts and separate CDI session contexts. + +== CDI Servlet and Extensions + +`@AddPackages(CdiInstantiator.class)` includes the Vaadin CDI infrastructure in the archive. +`VaadinExtension` and `BeanManagerProvider` connect that infrastructure to Weld's bean manager. +A CDI producer supplies the `CdiVaadinServlet` instance also passed to `MockVaadin.setup()`. +That servlet makes Vaadin resolve managed objects through `CdiInstantiator`. + +== Route Registration + +CDI bean discovery and Vaadin route registration are separate operations. +The custom setup registers navigation targets with `RouteConfiguration` after creating the CDI-aware service. +It does not invoke the base class's default `discoverRoutes()` path. + +An empty `@ViewPackages` annotation means the test package and its sub-packages in the default setup; it is not an instruction to disable route scanning. +The CDI recipe's explicit registration works because it replaces the default initialization method. + +== Boundaries + +The test supplies CDI injection and a simulated Vaadin environment, not all Jakarta EE services supplied by an application server. +Application-server authentication, persistence, transactions, and other deployment services need their own test configuration or integration tests. +Spring test annotations, Spring bean replacement, and Quarkus test profiles do not configure this Weld archive. + +The common component query and tester APIs remain available after CDI initialization. +The browserless JUnit extensions and plain `forComponent()` factories do not apply this custom CDI setup automatically. + + +== Custom Setup and Test Configuration + +The <<{articles}/building-apps/testing/browserless/setup-cdi#,CDI recipe>> passes its servlet and `lookupServices()` directly to `MockVaadin.setup()`. +It does not pass the configuration used by the standard setup, so <> settings are not automatically applied by that override. +The standard Spring and Quarkus integrations' `frameworkLookupServices()` guarantees do not configure this custom CDI servlet. +When extending the custom setup, keep servlet initialization, route registration, signal support, and configuration handling together. + + +[discussion-id]`52411CF1-7324-44CE-813B-8F76BBA923DC` diff --git a/articles/flow/testing/browserless/component-query.adoc b/articles/flow/testing/browserless/component-query.adoc index d19511da62..5f9eb51c21 100644 --- a/articles/flow/testing/browserless/component-query.adoc +++ b/articles/flow/testing/browserless/component-query.adoc @@ -75,13 +75,13 @@ The following table lists all available filter methods, grouped by category: | Matches components whose text content equals the given string exactly. | [methodname]`withTextContaining(String)` -| Matches components whose text content contains the given substring. +| Matches components whose text content contains the given text fragment. | [methodname]`withCaption(String)` | Matches components whose caption equals the given string exactly. | [methodname]`withCaptionContaining(String)` -| Matches components whose caption contains the given substring. +| Matches components whose caption contains the given text fragment. 2+h| Labels @@ -89,19 +89,19 @@ The following table lists all available filter methods, grouped by category: | Matches components whose `label` equals the given string exactly. Use this for form fields ([classname]`TextField`, [classname]`ComboBox`, and so on) where the end user identifies a field by its label. | [methodname]`withLabelContaining(String)` -| Matches components whose `label` contains the given substring. +| Matches components whose `label` contains the given text fragment. | [methodname]`withAriaLabel(String)` | Matches components whose `aria-label` equals the given string exactly. Useful for components like [classname]`Button` that don't carry a visible label property but identify themselves to assistive technology via `aria-label`. | [methodname]`withAriaLabelContaining(String)` -| Matches components whose `aria-label` contains the given substring. +| Matches components whose `aria-label` contains the given text fragment. | [since:com.vaadin:vaadin@V25.2]#[methodname]`withPlaceholder(String)`# | Matches [interfacename]`HasPlaceholder` components whose `placeholder` equals the given string exactly. Useful for toolbar or search fields that omit a stacked label and identify themselves to the user through placeholder text instead. | [since:com.vaadin:vaadin@V25.2]#[methodname]`withPlaceholderContaining(String)`# -| Matches [interfacename]`HasPlaceholder` components whose `placeholder` contains the given substring. +| Matches [interfacename]`HasPlaceholder` components whose `placeholder` contains the given text fragment. 2+h| CSS Classes & Themes @@ -112,7 +112,7 @@ The following table lists all available filter methods, grouped by category: | Excludes components that have any of the given CSS class names. | [methodname]`withTheme(ThemeVariant)` -| Matches components that have the given theme variant -- for example, [methodname]`withTheme(ButtonVariant.LUMO_PRIMARY)`. The typed variant autocompletes in the IDE and turns typos into compile errors. +| Matches components that have the given theme variant -- for example, [methodname]`withTheme(ButtonVariant.LUMO_PRIMARY)`. The typed variant supports IDE completion and turns typos into compile errors. | [methodname]`withoutTheme(ThemeVariant)` | Excludes components that have the given theme variant. @@ -194,7 +194,7 @@ submit.setTestId("submit-button"); Button submitButton = find(Button.class).testId("submit-button"); ---- -To combine a test ID with other filter conditions, use the [methodname]`withTestId()` filter method instead and finish the chain with a regular terminal operator. Since test IDs are expected to be unique, both forms fail if more than one component matches. See <> for more on test IDs. +To combine a test ID with other filter conditions, use the [methodname]`withTestId()` filter method instead and finish the chain with a regular terminal operator. Since test IDs are expected to be unique, both forms fail if more than one component matches. See <<{articles}/building-apps/testing/browserless/test-user-interactions#test-ids, Getting Started>> for more on test IDs. [[slots]] @@ -315,5 +315,6 @@ test(menu).open(); find(Div.class).withText("Rename").all(); ---- +For a worked example, see <<{articles}/building-apps/testing/browserless/test-user-interactions#,Test User Interactions>>. [discussion-id]`DDC7D136-1A56-44FC-B256-C15DB7645EDC` diff --git a/articles/flow/testing/browserless/component-testers.adoc b/articles/flow/testing/browserless/component-testers.adoc index c058f66cd4..ba2591d803 100644 --- a/articles/flow/testing/browserless/component-testers.adoc +++ b/articles/flow/testing/browserless/component-testers.adoc @@ -1,8 +1,8 @@ --- title: Component Testers -page-title: How to use and build component testers for browserless testing -description: Using built-in testers and building custom ones for your own components. -meta-description: Learn how to use Vaadin's built-in component testers and build custom testers for your own components in browserless tests. +page-title: Component Testers | Vaadin +description: Tester selection, supported actions, usability checks, constraints, and custom tester discovery. +meta-description: Tester selection, supported actions, usability checks, constraints, and custom tester discovery. order: 18 --- @@ -11,7 +11,9 @@ order: 18 Component testers simulate user interactions in browserless tests. Each tester wraps a specific component type and provides methods that mirror what a real user can do -- clicking, typing, and selecting items. -Testers focus on actions that simulate user behavior, such as [methodname]`setValue()`, [methodname]`click()`, and [methodname]`selectItem()`. To read component state -- like getting a value or checking visibility -- use the component's Java API directly. This separation keeps tester methods focused on simulating real user interactions that also perform usability checks. +Testers provide actions such as [methodname]`setValue()`, [methodname]`click()`, and [methodname]`selectItem()` with usability checks. +Some testers also expose component-specific inspection methods, such as grid row access and notification text. +The component's Java API remains available for reading values, visibility, and other state. == Using Component Testers @@ -153,10 +155,10 @@ The following table shows frequently used testers and their key methods: |=== -.selectItem Takes String Labels +.Selection Uses String Labels [TIP] ==== -The [methodname]`selectItem()` method on [classname]`SelectTester` and [classname]`ComboBoxTester` takes the *display label as a String*, not the typed item value. When using enums or other objects, pass the label that the user would see in the dropdown: +The [methodname]`selectItem()` method on [classname]`SelectTester` and [classname]`ComboBoxTester` takes the *display label as a String*, not the typed item value. When using enums or other objects, pass the label that the user would see in the drop-down: [source,java] ---- @@ -281,116 +283,27 @@ All testers inherit from [classname]`ComponentTester`, which provides methods us [role="since:com.vaadin:vaadin@V25.2"] -== Testing a Component Without a View +== Standalone Component Environments -A single component -- for example, a form or a custom field -- can be tested in isolation, without wrapping it in an [annotationname]`@Route` view. [methodname]`BrowserlessUIContext.forComponent()` builds a self-contained, route-free test environment, attaches the component to a window's [classname]`UI`, and tears everything down when the window closes, so a single try-with-resources is enough: +`BrowserlessUIContext.forComponent(component)` creates a route-free environment and attaches the component to its UI. +Closing the returned context tears down the environment. +The supplier overload creates the component after installing the Vaadin thread-locals, allowing its constructor to access the current UI and session. +`BrowserlessApplicationContext.forComponent(Supplier)` creates a fresh component for each new window. -[source,java] ----- -try (var window = BrowserlessUIContext.forComponent(new MyForm())) { - window.findTextField().withLabel("Name").setValue("Ada"); - window.findButton().withText("Save").click(); -} ----- - -The returned window is a [classname]`BrowserlessUIContext`, so the full testing DSL is available: [methodname]`find()`, [methodname]`test()`, and the typed locator entry points such as [methodname]`findButton()`. See <> for the context API. - -If the component's constructor needs [methodname]`UI.getCurrent()` or the session, pass a factory instead: [methodname]`BrowserlessUIContext.forComponent(MyForm::new)`. The factory runs after the Vaadin thread-locals are installed, so the constructor observes the live environment. - -For tests that need the same standalone component in several windows or for several users, use [methodname]`BrowserlessApplicationContext.forComponent(Supplier)`. It returns the application context, and every window created from it gets a fresh component instance from the factory: - -[source,java] ----- -try (var app = BrowserlessApplicationContext.forComponent(MyForm::new)) { - var w1 = app.newUser().newWindow(); - var w2 = app.newUser().newWindow(); - - w1.findTextField().withLabel("Name").setValue("Ada"); - // w2 holds its own MyForm instance - Assertions.assertEquals("", w2.findTextField().withLabel("Name") - .component().getValue()); -} ----- - - -== Building Custom Testers +== Custom Tester Contracts -When you create custom components, you can build testers for them too. Custom testers extend [classname]`ComponentTester` and use the [annotationname]`@Tests` annotation to declare which component they test. +Custom testers extend `ComponentTester` and declare the component type with `@Tests`. +The `fqn` attribute accepts a fully qualified class name when a class literal is unsuitable. +Custom action methods call `ensureComponentIsUsable()` before performing the interaction. +They can delegate to other component testers. +Tester discovery scans `com.vaadin.flow.component` by default. +`@ComponentTesterPackages` adds application packages to scan for testers. +Extensions and application-context builders also support tester-package configuration. -=== Defining a Custom Tester - -[source,java] ----- -// Tests defines the components this tester should be used for automatically -@Tests(PersonFormView.PhoneNumberField.class) -public class PhoneNumberFieldTester extends ComponentTester { - // Other testers can be used inside the custom tester - final ComboBoxTester, String> combo_; - final TextFieldTester number_; - - public PhoneNumberFieldWrap(PersonFormView.PhoneNumberField component) { - super(component); - combo_ = new ComboBoxTester<>( - getComponent().countryCode); - number_ = new TextFieldTester<>(getComponent().number); - } - - public List getCountryCodes() { - return combo_.getSuggestionItems(); - } - - public void setCountryCode(String code) { - ensureComponentIsUsable(); - if(!getCountryCodes().contains(code)) { - throw new IllegalArgumentException("Given code isn't available for selection"); - } - combo_.selectItem(code); - } - - public void setNumber(String number) { - ensureComponentIsUsable(); - number_.setValue(number); - } - - public String getValue() { - return getComponent().generateModelValue(); - } - -} ----- +For a worked example, see <<{articles}/building-apps/testing/browserless/test-custom-components#,Test a Custom Component>>. -.`PhoneNumberField.java` -[source,java] ----- -static class PhoneNumberField extends CustomField { - ComboBox countryCode = new ComboBox<>(); - TextField number = new TextField(); - - // ... -} ----- - -Custom testers can use other testers internally, as shown above with `ComboBoxTester` and `TextFieldTester`. - -.Generic Components -[TIP] -The [annotationname]`@Tests` annotation also has an [methodname]`fqn` attribute that accepts fully qualified class names as strings. Use this when the component type uses generics that prevent it from being passed as a class literal: -`@Tests(fqn = "com.example.MyField")`. - - -=== Registering Custom Testers - -By default, tester implementations are scanned from the `com.vaadin.flow.component` package, so adding a custom tester to that package makes it immediately available. - -To place custom testers in another package, annotate the test class with [annotationname]`@ComponentTesterPackages`: - -[source,java] ----- -@ComponentTesterPackages("com.example.application.views.personform") -class PersonFormViewTest extends BrowserlessTest { -} ----- +For the procedures previously covered here, [[building-custom-testers]][[defining-a-custom-tester]][[registering-custom-testers]][[testing-a-component-without-a-view]]see <<{articles}/building-apps/testing/browserless/test-custom-components#,the Building Apps guide>>. [discussion-id]`A1B2C3D4-E5F6-7890-ABCD-EF1234567890` diff --git a/articles/flow/testing/browserless/environment-differences.adoc b/articles/flow/testing/browserless/environment-differences.adoc index 6760543647..e1546db574 100644 --- a/articles/flow/testing/browserless/environment-differences.adoc +++ b/articles/flow/testing/browserless/environment-differences.adoc @@ -9,120 +9,58 @@ 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. - +The standard browserless setup creates a mocked Vaadin environment around each test method. +Framework integration determines when dependency injection and authentication become available. +The scope and security behavior below applies to Spring tests. The <> has its own Weld lifecycle and explicit route setup. [#test-lifecycle] -== Test Lifecycle - -For a [classname]`SpringBrowserlessTest`, each test method runs through these steps: +== Spring Test Lifecycle -. 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. +For `SpringBrowserlessTest`, the default sequence is: -[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. +. JUnit creates the test instance and Spring injects its `@Autowired` fields, before the Vaadin environment exists. +. JUnit extension callbacks run. With their default timing, Spring Security test annotations populate `SecurityContextHolder`. +. The base class's `@BeforeEach` method creates the Vaadin service, session, and UI and navigates to the root route. +. Subclass `@BeforeEach` methods and the test method can use that environment. +. The inherited cleanup closes the Vaadin environment after the test. +The environment is therefore available after its setup hook, not during initial test-instance injection. +Plain Java and Quarkus tests use their own framework setup; the Spring injection and security steps do not apply to them. +See <> for the common APIs. [#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; +== Spring Session-Scoped Beans - @Test - void addItem_cartContainsItem() { - CartView view = navigate(CartView.class); - Cart cart = cartProvider.getObject(); +Resolving a `@VaadinSessionScope` or Spring `@SessionScope` bean during test-instance injection fails because no session exists yet. +This failure affects every test in the class, including methods that do not use the field. +An `ObjectProvider` defers resolution until `getObject()` is called after environment setup. +A lazy proxy with `@Lazy`, or an `ApplicationContext.getBean()` call made after setup, can also defer lookup. - 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. +Views created during initial or explicit navigation already have the Vaadin environment available, so their session-scoped dependencies can resolve then. +In <>, request- and session-scoped beans resolve for the active user; activate the intended window before resolving a bean directly. +This Spring behavior does not establish CDI session-context isolation. +For a complete test, see <<{articles}/building-apps/testing/browserless/setup-spring-boot#session-scoped-beans,Access Session-Scoped Beans>>. [#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. +With their default timing, Spring Security's `@WithMockUser`, `@WithAnonymousUser`, and `@WithUserDetails` establish authentication before the environment's initial navigation. +View access control therefore observes the simulated user during that navigation. -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. +Authentication established in the test body, or with `setupBefore = TestExecutionEvent.TEST_EXECUTION`, arrives after initial navigation. +Subsequent requests use that authentication, but `getCurrentView()` still returns the previously rendered view until another navigation takes place. -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. +`Page.reload()` recreates the UI at the currently active location. +If initial navigation redirected to the login view, reloading renders the login location again; it does not navigate to the original protected destination. +For a worked Spring test, see <<{articles}/building-apps/testing/browserless/test-view-access#authentication-applied-during-a-test,Navigate after Changing Authentication>>. +See <> for setup requirements and session ID rotation. == 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. - +Queries traverse the server-side component tree. +Per-item renderer components may not exist until rendering is requested, and closed overlay content is detached. +See <> for renderer and overlay semantics. [discussion-id]`4B6C9E13-7A85-42D0-9F3B-1C8E5D4A2B76` diff --git a/articles/flow/testing/browserless/extensions.adoc b/articles/flow/testing/browserless/extensions.adoc index 93a64d0e2a..7fa7ab3d31 100644 --- a/articles/flow/testing/browserless/extensions.adoc +++ b/articles/flow/testing/browserless/extensions.adoc @@ -1,8 +1,8 @@ --- title: JUnit 6 Extensions -page-title: Composition-based browserless testing with JUnit 6 extensions -description: Use BrowserlessExtension and BrowserlessClassExtension as an alternative to extending BrowserlessTest. -meta-description: Set up Vaadin browserless tests with JUnit 6 @RegisterExtension when extending a base class is not an option. +page-title: JUnit 6 Extensions | Vaadin +description: Composition-based browserless test setup, per-method and per-class lifecycles, and extension configuration. +meta-description: Composition-based browserless test setup, per-method and per-class lifecycles, and extension configuration. order: 45 --- @@ -12,7 +12,8 @@ order: 45 Extending [classname]`BrowserlessTest` is the most compact way to write browserless tests, but it requires your test class to use inheritance for the Vaadin setup. When a project already has its own test base class, or you prefer composition over inheritance, the [classname]`BrowserlessExtension` and [classname]`BrowserlessClassExtension` JUnit 6 extensions provide the same functionality without requiring a specific superclass. [NOTE] -These extensions are part of the `browserless-test-junit6` artifact. They don't replace [classname]`SpringBrowserlessTest` or [classname]`QuarkusBrowserlessTest` -- for Spring and Quarkus projects, continue to extend those base classes. +These extensions are part of the `browserless-test-junit6` artifact. +They do not apply the custom servlet and Weld initialization from <>. They don't replace [classname]`SpringBrowserlessTest` or [classname]`QuarkusBrowserlessTest` -- for Spring and Quarkus projects, continue to extend those base classes. == When to Use an Extension @@ -29,21 +30,8 @@ Use an extension when any of the following applies: [source,java] ---- -@ViewPackages(classes = CartView.class) -class CartViewTest { - - @RegisterExtension - BrowserlessExtension ext = new BrowserlessExtension(); - - @Test - void addItemToCart() { - ext.navigate(CartView.class); - ext.findButton().withText("Add to cart").click(); - - Assertions.assertEquals("1 item", - ext.findSpan().withId("cart-size").getText()); - } -} +@RegisterExtension +BrowserlessExtension ext = new BrowserlessExtension(); ---- Navigation, queries, and tester interactions are available as methods on the extension instance -- [methodname]`ext.navigate()`, [methodname]`ext.find()`, [methodname]`ext.findInView()`, [methodname]`ext.test()`, [methodname]`ext.getCurrentView()`, [methodname]`ext.fireShortcut()`, [methodname]`ext.roundTrip()`, and [methodname]`ext.runPendingSignalsTasks()`. The extension also exposes typed locator entry points such as [methodname]`ext.findButton()` and [methodname]`ext.findTextField()`; see <>. @@ -55,31 +43,14 @@ Navigation, queries, and tester interactions are available as methods on the ext [source,java] ---- -@ViewPackages(classes = CartView.class) -class CartViewSharedTest { - - @RegisterExtension - static BrowserlessClassExtension ext = new BrowserlessClassExtension(); - - @BeforeAll - static void setup() { - ext.navigate(CartView.class); - } - - @Test - void addItem() { - // same UI instance as removeItem - ext.findButton().withText("Add").click(); - } - - @Test - void removeItem() { - // state from addItem is preserved - } -} +@RegisterExtension +static BrowserlessClassExtension ext = new BrowserlessClassExtension(); ---- -The same trade-offs as <> apply: state leaks between tests, so write tests that tolerate leftover state or reset it explicitly. +The session and UI are shared by all test methods in the class. +State changes persist between methods; the extension does not reset application state. +Base-class tests always recreate the environment per method. +For a practical comparison, see <<{articles}/building-apps/testing/browserless/speed-up-tests#sharing-the-vaadin-environment-across-tests,Sharing the Vaadin Environment Across Tests>>. == Configuring the Extension @@ -136,5 +107,7 @@ class AdminViewTest { } ---- +For a worked example, see <<{articles}/building-apps/testing/browserless/setup-without-spring#,Set Up Browserless Tests in Plain Java>>. + [discussion-id]`B51F9D4A-2E73-4B18-8C6F-9A3D7E2B1C04` diff --git a/articles/flow/testing/browserless/getting-started.adoc b/articles/flow/testing/browserless/getting-started.adoc deleted file mode 100644 index 245ce9c562..0000000000 --- a/articles/flow/testing/browserless/getting-started.adoc +++ /dev/null @@ -1,260 +0,0 @@ ---- -title: Getting Started -page-title: How to get started with Vaadin browserless testing -description: Tutorial to create and run a browserless test. -meta-description: A beginner-friendly guide on how to set up and run a browserless test in Vaadin. -order: 10 ---- - - -= Getting Started with Browserless Testing - -To start creating browserless tests in an existing Spring Boot project, add the `browserless-test-spring` dependency with a `test` scope. Spring Boot's test starter must also be present -- it's typically already on the classpath in Spring Boot projects. - -.Non-Spring Projects -[TIP] -This guide assumes a Spring Boot project. If you aren't using Spring, see <> for a simpler setup. - -Assuming you've imported the Vaadin Bill-of-Materials (BOM) and have a Maven project, add the following: - -[source,xml] ----- - - com.vaadin - browserless-test-spring - test - - - org.springframework.boot - spring-boot-starter-test - test - ----- - - -== First Browserless Test - -In Spring Boot projects, views typically use dependency injection for services and other components. To handle this correctly, browserless testing provides a specialized base class: [classname]`SpringBrowserlessTest`. Annotate your test class with [annotationname]`@SpringBootTest` so that the full application context is available. - -Given a simple view like this: - -.`HelloWorldView.java` -[source,java] ----- -@Route("") -public class HelloWorldView extends HorizontalLayout { - - TextField name; - Button sayHello; - - public HelloWorldView() { - name = new TextField("Your name"); - sayHello = new Button("Say hello"); - sayHello.addClickListener(e -> { - Notification.show("Hello " + name.getValue()); - }); - add(name, sayHello); - } -} ----- - -A browserless test for it looks like this: - -.`HelloWorldViewTest.java` -[source,java] ----- -@SpringBootTest -class HelloWorldViewTest extends SpringBrowserlessTest { - - @Test - public void setText_clickButton_notificationIsShown() { - final HelloWorldView helloView = navigate(HelloWorldView.class); - - test(helloView.name).setValue("Test"); - test(helloView.sayHello).click(); - - Notification notification = find(Notification.class).single(); - Assertions.assertEquals("Hello Test", test(notification).getText()); - } - -} ----- - -The following sections break down what this test does. - - -=== Navigating to a View - -The [methodname]`navigate()` method opens a view, just as a user would navigate to it in the browser. It returns the view instance so you can interact with it directly. - -[source,java] ----- -final HelloWorldView helloView = navigate(HelloWorldView.class); ----- - - -=== Using the Java API Directly - -Since you're running on the server side, you have direct access to the Java component API. In the example above, the [classname]`TextField` and [classname]`Button` fields are package-protected. This means the test class can access them directly, as long as it's in the same Java package -- for example, if the view is in `src/main/java/com/example/app/`, put the test in `src/test/java/com/example/app/`. - -[source,java] ----- -// Read a component's value directly -String currentValue = helloView.name.getValue(); - -// Check component state -boolean isEnabled = helloView.sayHello.isEnabled(); -boolean isVisible = helloView.name.isVisible(); ----- - - -=== Simulating User Actions with Testers - -To simulate how a user interacts with a component, wrap it with [methodname]`test()`. This returns a component-specific tester that provides methods like [methodname]`setValue()`, [methodname]`click()`, and [methodname]`getText()`. Unlike calling the Java API directly, tester methods also verify that the component is in a usable state -- visible, enabled, and attached to the UI. - -[source,java] ----- -// Simulate typing into a text field -test(helloView.name).setValue("Test"); - -// Simulate clicking a button -test(helloView.sayHello).click(); - -// Read the text a user would see -String text = test(notification).getText(); ----- - -Each Vaadin component has a tester tailored to its behavior. For example, a [classname]`CheckboxTester` uses [methodname]`click()` to toggle checked state, a [classname]`ComboBoxTester` has [methodname]`selectItem()`, and a [classname]`GridTester` has [methodname]`getRow()`. See <> for a full overview and how to build testers for your own components. - - -=== Finding Components - -Not every component is stored in a view field. For example, the [classname]`Notification` in the test above is created inside a click listener and isn't referenced anywhere in the view. Use the [methodname]`find()` query method to find components in the UI by their type: - -[source,java] ----- -// Find the single Notification currently open -Notification notification = find(Notification.class).single(); ----- - -The query API supports filtering by properties, predicates, and scoping to specific parts of the component tree. See <> for details. - - -[#test-ids] -== Test IDs - -Use [methodname]`Component.setTestId()` to assign a stable identifier to a component for use in tests. This sets the `data-testid` HTML attribute on the component's element, which can be used by testing frameworks like Playwright to locate elements reliably: - -[source,java] ----- -Button submitButton = new Button("Submit"); -submitButton.setTestId("submit-button"); - -// Later, retrieve the test ID -String testId = submitButton.getTestId(); // "submit-button" ----- - -Test IDs are preferable to CSS class names or text content for locating elements in tests because they are stable, decoupled from styling, and unaffected by translations or UI changes. - -In a Playwright test, you can locate the element using the `data-testid` attribute: - -[source,java] ----- -page.locator("[data-testid='submit-button']").click(); ----- - -Browserless tests can use the same identifier: the [since:com.vaadin:vaadin@V25.2]#`testId()` terminal operator and the `withTestId()` filter# look up components by their test ID: - -[source,java] ----- -Button submit = find(Button.class).testId("submit-button"); ----- - -See <> for details. - - -== Running Tests - -Testing with [classname]`SpringBrowserlessTest` doesn't require any particular setup beyond the dependencies above. Run the test directly from your IDE or use Maven, for example by typing `mvn test` in the terminal. - - -== Navigating to Views - -On test initialization, the loaded view is the root view. - -To navigate to another registered view, use the [methodname]`navigate()` methods provided by the base class: - -- For a normal view with only a path defined -+ -[methodname]`navigate(MyView.class)` -+ -[methodname]`navigate("myView", MyView.class)` -- For a view with [interfacename]`HasUrlParameter` -+ -[methodname]`navigate(MyParam.class, "parameter")` -+ -[methodname]`navigate("myParam/parameter", MyParam.class)` -- For a view with URL template `@Route("template/:param")` -+ -[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. - -[NOTE] -Navigation by location string takes in the view class, so that the initialized view can be automatically validated to be the expected one. - -You can also retrieve the current view at any time with [methodname]`getCurrentView()`: - -[source,java] ----- -HasElement view = getCurrentView(); ----- - - -== Simulating Keyboard Shortcuts - -Use [methodname]`fireShortcut()` to simulate keyboard shortcuts registered with Vaadin's shortcut API: - -[source,java] ----- -// Simulate pressing Enter -fireShortcut(Key.ENTER); - -// Simulate Ctrl+S -fireShortcut(Key.KEY_S, KeyModifier.CONTROL); ----- - - -[#signals] -== Testing Signal-Based UIs - -Views built on <<{articles}/flow/ui-state#, signals>> are testable without extra setup: [classname]`BrowserlessTest` registers a test [classname]`SignalEnvironment`, so signal effects run deterministically on the test thread instead of on a background thread pool. Signal changes made directly by a simulated user action are reflected in the UI immediately, so the testers and queries see the updated state right away. For a deeper look see <>. - -When a signal is changed from a background thread -- for example, by a service callback or another part of the application -- call [methodname]`runPendingSignalsTasks()` to process the pending signal effects before asserting: - -[source,java] ----- -// Application code updates a signal from a background thread -test(view.start).click(); - -// Process the pending signal effects, then assert -runPendingSignalsTasks(); -Assertions.assertEquals("Completed", test(view.status).getText()); ----- - -The same method is available on the JUnit extension as [methodname]`ext.runPendingSignalsTasks()` (see <>) and on each window in multi-user tests (see <>). - - -.Multiple Users or Windows? -[TIP] -This setup drives a single user with a single window. For tests that need several concurrent users or multiple windows of the same user, see <>. - - -[discussion-id]`7F423DA0-1C41-44BA-B832-55C269FA9311` diff --git a/articles/flow/testing/browserless/index.adoc b/articles/flow/testing/browserless/index.adoc index 26df93a659..dd6746fffb 100644 --- a/articles/flow/testing/browserless/index.adoc +++ b/articles/flow/testing/browserless/index.adoc @@ -12,7 +12,7 @@ page-links: = Browserless Testing [NOTE] -Browserless testing was previously known as _UI Unit Testing_ and required a commercial TestBench subscription. Starting with Vaadin 25.1, browserless testing is free for all users. +Browserless testing was previously known as _UI Unit Testing_ and required a commercial TestBench subscription. [since:com.vaadin:vaadin@V25.1]#Browserless testing is free for all users#. Browserless testing removes the need to run a browser or a servlet container, making it much faster to test your Vaadin-based applications. @@ -23,11 +23,16 @@ You also don't need to launch a servlet container. The [classname]`BrowserlessTe In addition to browserless testing, Vaadin also allows you to write end-to-end tests for your applications. <<../#comparison,Each approach has its own advantages>>. +Start by <<{articles}/building-apps/testing/browserless#choose-your-framework,choosing your application framework>> for setup and a runnable example. +The following pages describe APIs, lifecycle guarantees, and integration behavior. + + == Topics section_outline::[] [NOTE] -Browserless testing isn't supported in Java EE based projects due to technical limitations in the Weld JUnit 5 extension. Use End-to-End testing instead. +Java EE / Jakarta EE applications using Vaadin CDI need a Weld-backed test environment and a CDI servlet. +See <> for the lifecycle and framework boundaries. [discussion-id]`17590340-7B0A-463B-846B-FEDB1F1AE1B3` diff --git a/articles/flow/testing/browserless/locators.adoc b/articles/flow/testing/browserless/locators.adoc index a5056b7f94..41203ea9ef 100644 --- a/articles/flow/testing/browserless/locators.adoc +++ b/articles/flow/testing/browserless/locators.adoc @@ -1,8 +1,8 @@ --- title: Component Locators -page-title: Using locators in Vaadin browserless tests -description: Drive components with a fluent locator that combines a query filter chain and tester actions. -meta-description: Use the Vaadin browserless locator API to assert and act on components in a single fluent expression -- replacing find().single() plus test() boilerplate. +page-title: Component Locators | Vaadin +description: Typed component locator entry points, filters, resolution caching, custom locators, and opt-in interfaces. +meta-description: Typed component locator entry points, filters, resolution caching, custom locators, and opt-in interfaces. order: 22 --- @@ -42,56 +42,16 @@ int rows = window.findGrid(Person.class).size(); == Filter Chain -Locators share their filter vocabulary with [classname]`ComponentQuery`: +Locators share their filter vocabulary with <>. +This includes [since:com.vaadin:vaadin@V25.3]#`withinSlot(String)`#; see <> for slot ownership and nesting. +Locator-specific operations are: [cols="1,2"] |=== | Method | Description - -| [methodname]`withId(String)` -| Matches the component with the given `id`. - -| [methodname]`withTestId(String)` -| Matches the component with the given test ID (the `data-testid` attribute set with [methodname]`Component.setTestId()`). Test IDs are treated as unique: at most one match is expected. - -| [methodname]`withLabel(String)` / [methodname]`withLabelContaining(String)` -| Matches by exact `label` or by label substring. Use this for form fields where the end user identifies a field by its label. - -| [methodname]`withText(String)` / [methodname]`withTextContaining(String)` -| Matches by exact text content or by substring. - -| [methodname]`withAriaLabel(String)` / [methodname]`withAriaLabelContaining(String)` -| Matches by exact `aria-label` or by aria-label substring -- useful for components like [classname]`Button` that have no label property but identify themselves to assistive technology. - -| [since:com.vaadin:vaadin@V25.2]#[methodname]`withPlaceholder(String)` / [methodname]`withPlaceholderContaining(String)`# -| Matches by exact `placeholder` or by placeholder substring -- useful for toolbar or search fields that omit a stacked label and identify themselves to the user through placeholder text instead. Only appears on locators whose component implements [interfacename]`HasPlaceholder`. - -| [methodname]`withClassName(String...)` / [methodname]`withoutClassName(String...)` -| Matches components carrying all (or none) of the given CSS class names. - -| [methodname]`withTheme(ThemeVariant)` / [methodname]`withoutTheme(ThemeVariant)` -| Matches components with (or without) the given theme variant -- for example, [methodname]`withTheme(ButtonVariant.LUMO_PRIMARY)`. Deprecated [code]`String` overloads remain for theme names not surfaced through a [classname]`ThemeVariant` enum, such as custom themes. - -| [methodname]`withAttribute(String)` / [methodname]`withAttribute(String, String)` / [methodname]`withoutAttribute(...)` -| Matches by attribute presence and value. - -| [since:com.vaadin:vaadin@V25.3]#[methodname]`withinSlot(String)`# -| Matches components sitting in the given named slot of the component that hosts them -- for example `footer` on a [classname]`Card`. Everything below a slot belongs to it. See <>. - -| [methodname]`withValue(V)` -| Matches [interfacename]`HasValue` components whose value equals the given value. - -| [methodname]`withCondition(Predicate)` -| Matches components satisfying a custom predicate. - -| [methodname]`inside(Component)` / [methodname]`inside(Locator)` -| Scopes the search to descendants of the given component (or of the component matched by the given locator -- resolved lazily on first action). - -| [methodname]`atIndex(int)` -| Picks the n-th match (1-based) when the filter chain yields more than one. - -| [methodname]`with(UnaryOperator>)` -| Escape hatch for filters not exposed on the locator (for example, [methodname]`withPropertyValue` or [methodname]`withResultsSize`). +| `inside(Component)` / `inside(Locator)` | Scopes the search to descendants. A locator scope resolves lazily on the first action. +| `atIndex(int)` | Selects the n-th match, using a one-based index. +| `with(UnaryOperator>)` | Applies query operations not directly exposed by the locator, such as `withPropertyValue` and `withResultsSize`. |=== [NOTE] @@ -157,41 +117,14 @@ window.use(form.submit).click(); == Custom Locators -For composites, page objects, or domain-specific widgets, subclass [classname]`Locator` with the recursive self-type so filter steps stay chainable, and expose the actions you want the test to see. Scope inner queries with [methodname]`inside(this)` so they only match descendants of the resolved composite: - -[source,java] ----- -public class PersonFormLocator - extends Locator { - - 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(); - } -} ----- - -Tests reach the custom locator through [methodname]`find(Supplier<L>)`: - -[source,java] ----- -window.find(PersonFormLocator::new) - .fillIn("Ada", "ada@example.com") - .submit(); ----- +A custom locator extends `Locator` with its own type as `L` to preserve fluent return types. +Inner locators use `inside(this)` to scope queries to the resolved composite. +`find(Supplier)` creates a custom locator through its factory. +For a worked example, see <<{articles}/building-apps/testing/browserless/test-custom-components#,Test a Custom Component>>. [#opting-in] -== Opting In for `BrowserlessTest` +== Opting in for `BrowserlessTest` To use locator entry points in a test class that extends [classname]`BrowserlessTest`, [classname]`SpringBrowserlessTest`, or [classname]`QuarkusBrowserlessTest`, declare that the class implements [interfacename]`Locators`: diff --git a/articles/flow/testing/browserless/multi-user.adoc b/articles/flow/testing/browserless/multi-user.adoc index c56235f89e..08037df8ab 100644 --- a/articles/flow/testing/browserless/multi-user.adoc +++ b/articles/flow/testing/browserless/multi-user.adoc @@ -1,13 +1,13 @@ --- -title: Multi-User and Multi-Window Testing -page-title: Browserless testing for multi-user and multi-window scenarios in Vaadin -description: Drive multiple concurrent user sessions and multiple browser windows in a single browserless test. -meta-description: Test Vaadin features that depend on multiple users or multiple windows -- shared state, security isolation, and per-window UI state -- without a browser. +title: Application, User, and Window Contexts +page-title: Application, User, and Window Contexts | Vaadin +description: Browserless context ownership, thread affinity, window activation, security isolation, factories, and configuration. +meta-description: Browserless context ownership, thread affinity, window activation, security isolation, factories, and configuration. order: 32 --- -= [since:com.vaadin:vaadin@V25.2]#Multi-User and Multi-Window Testing# += [since:com.vaadin:vaadin@V25.2]#Application, User, and Window Contexts# [classname]`BrowserlessTest` and the JUnit 6 extensions cover the most common scenario: one user, one window. Some features can only be exercised with multiple participants in the same test -- application-wide singletons observed by two users, per-window UI state of the same user, or security context isolation when switching between authenticated users. @@ -22,12 +22,15 @@ Reach for [classname]`BrowserlessApplicationContext` when: - A single user has multiple browser windows open and per-window UI state must remain isolated while session state is shared. - A test switches between authenticated users and needs the Spring Security or Quarkus security context to follow the active window. -For tests with a single user and a single window, prefer <>, <>, <>, or the <>. +For tests with a single user and a single window, prefer <<{articles}/building-apps/testing/browserless/setup-spring-boot#, `SpringBrowserlessTest`>>, <<{articles}/building-apps/testing/browserless/setup-without-spring#, `BrowserlessTest`>>, <>, or the <>. == Application, User, and Window -The API exposes three nested contexts that mirror Vaadin's runtime hierarchy: +The API exposes three nested contexts that mirror Vaadin's runtime hierarchy. +The <> does not automatically map these users to independent CDI session contexts. + +The context types are: [cols="1,3"] |=== @@ -47,87 +50,20 @@ All three contexts are *thread-affine*: within a single test, each context must When the test calls a DSL method on any [classname]`BrowserlessUIContext`, the API automatically switches the thread-local Vaadin state -- [classname]`VaadinService`, [classname]`VaadinSession`, [classname]`UI`, request, response, and the security context -- to that window's user. Interleaving calls on different windows is therefore safe without any explicit context switch. -== Setting Up the Application Context +== Application Context Lifecycle -The application context is built once per test, typically in [annotationname]`@BeforeEach`, and closed in [annotationname]`@AfterEach`. Use try-with-resources or call [methodname]`close()` explicitly: closing the application context cascades to every user and window it created. +The application context owns the users and windows it creates. It implements explicit resource cleanup through [methodname]`close()`: closing the application context cascades to every user and window it created. [methodname]`create()` accepts the packages that contain [annotationname]`@Route`-annotated views, either as package names or as classes whose packages should be scanned. Passing classes plays well with IDE refactoring and is the preferred form. -.Plain Java -[source,java] ----- -try (var app = BrowserlessApplicationContext.create(CartView.class)) { - var user = app.newUser(); - var window = user.newWindow(); - window.navigate(CartView.class); - // assertions... -} ----- - -For Spring and Quarkus, dedicated factories pre-wire the framework-specific servlet and lookup initializer: -.Spring -[source,java] ----- -@ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = ShopTestConfig.class) -class CartViewMultiUserTest { - - @Autowired - private ApplicationContext applicationContext; - - private BrowserlessApplicationContext app; - - @BeforeEach - void setUp() { - app = SpringBrowserlessApplicationContext.create(applicationContext, - CartView.class); - } - - @AfterEach - void tearDown() { - app.close(); - } -} ----- - -.Quarkus -[source,java] ----- -@QuarkusTest -class CartViewMultiUserTest { - - private BrowserlessApplicationContext app; - - @BeforeEach - void setUp() { - app = QuarkusBrowserlessApplicationContext.create(CartView.class); - } - - @AfterEach - void tearDown() { - app.close(); - } -} ----- +The `SpringBrowserlessApplicationContext` and `QuarkusBrowserlessApplicationContext` factories pre-wire the framework-specific servlet and lookup initialization. The Spring factory also accepts the Spring application context. == Creating Users and Windows [methodname]`newUser()` returns a fresh [classname]`BrowserlessUserContext` with its own [classname]`VaadinSession`. [methodname]`newWindow()` creates a new [classname]`UI` for that user. Different users have independent sessions; different windows of the same user share a session but have independent [classname]`UI` instances. -.Two Users, Independent Sessions -[source,java] ----- -var alice = app.newUser(); -var aliceWindow = alice.newWindow(); - -var bob = app.newUser(); -var bobWindow = bob.newWindow(); - -Assertions.assertNotSame(alice.getSession(), bob.getSession()); -Assertions.assertNotSame(aliceWindow.getUI(), bobWindow.getUI()); ----- Every window exposes the same testing DSL as [classname]`BrowserlessTest`, scoped to that window -- no explicit activation is required: @@ -139,70 +75,14 @@ Every window exposes the same testing DSL as [classname]`BrowserlessTest`, scope - [methodname]`window.runPendingSignalsTasks()` -- process pending signal effects before asserting; see <<#signals, Signals>> below. - [methodname]`window.getCurrentView()` and [methodname]`window.roundTrip()` -- convenience accessors that mirror their [classname]`BrowserlessTest` counterparts. -.Two Users Sharing Application-Level State -[source,java] ----- -var w1 = app.newUser().newWindow(); -w1.navigate(SharedCounterView.class); - -var w2 = app.newUser().newWindow(); -w2.navigate(SharedCounterView.class); - -// w1 mutates a shared static counter -w1.findButton().withText("Increment").click(); -Assertions.assertEquals("Count: 1", w1.findParagraph().getText()); - -// w2 still shows its own UI state until it refreshes -Assertions.assertEquals("Count: 0", w2.findParagraph().getText()); - -w2.findButton().withText("Refresh").click(); -Assertions.assertEquals("Count: 1", w2.findParagraph().getText()); ----- - -.Same User, Two Windows, Independent UI State -[source,java] ----- -var user = app.newUser(); -var w1 = user.newWindow(); -var w2 = user.newWindow(); - -w1.navigate(CartView.class); -w2.navigate(CheckoutView.class); - -// Each window holds its own current view -Assertions.assertInstanceOf(CartView.class, w1.getCurrentView()); -Assertions.assertInstanceOf(CheckoutView.class, w2.getCurrentView()); - -// Session is the same; UIs are not -Assertions.assertSame(user.getSession(), w1.getUI().getSession()); -Assertions.assertNotSame(w1.getUI(), w2.getUI()); ----- - [#signals] == Signals -The application context registers the test [classname]`SignalEnvironment`, so signal effects run deterministically instead of on a background thread pool. For signal testing in single-user tests -- and a fuller treatment of synchronous propagation -- see <>. When one window mutates a signal that other windows observe -- the typical pattern for collaborative features built on shared signals -- call [methodname]`runPendingSignalsTasks()` to process the pending effects before asserting on the observing window: +The application context registers the test [classname]`SignalEnvironment`, so signal effects run deterministically instead of on a background thread pool. For signal testing in single-user tests -- and a fuller treatment of synchronous propagation -- see <>. When one window mutates a signal that other windows observe -- the typical pattern for collaborative features built on shared signals -- call [methodname]`runPendingSignalsTasks()` to process the pending effects before asserting on the observing window. -.Two Users Observing a Shared Signal -[source,java] ----- -var w1 = app.newUser().newWindow(); -w1.navigate(ChatView.class); -var w2 = app.newUser().newWindow(); -w2.navigate(ChatView.class); - -// w1 updates a shared signal that both views are bound to -w1.findTextField().withLabel("Message").setValue("Hello!"); -w1.findButton().withText("Send").click(); - -// Process the pending signal effects, then assert on the other window -w2.runPendingSignalsTasks(); -Assertions.assertEquals("Hello!", w2.findParagraph().getText()); ----- - -[methodname]`runPendingSignalsTasks()` waits up to 100 milliseconds for the first pending task to arrive and then drains the queue; the [methodname]`runPendingSignalsTasks(long, TimeUnit)` overload accepts a custom wait time. The method returns [code]`true` if any tasks were processed. If the calling thread holds the window's [classname]`VaadinSession` lock, the lock is temporarily released during the wait so that background threads can enqueue tasks. +See <> for waiting, return values, and session-lock handling. The same call also confirms shared-signal writes made through the window: the [classname]`SignalOperation` returned by a write completes only after the queue has been drained. Write the signal while the window's thread-locals are active -- either from within a DSL call or after [methodname]`window.activate()` -- so that the confirmation is dispatched through the window's UI. See <> for details. @@ -213,51 +93,6 @@ When Spring Security is on the classpath, [methodname]`SpringBrowserlessApplicat When the test switches between windows belonging to different users, the outgoing user's [classname]`SecurityContext` is saved and the incoming user's snapshot is restored automatically. -.Multi-User Security Isolation -[source,java] ----- -@ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = SecurityTestConfig.class) -class MultiUserSecurityTest { - - @Autowired - private ApplicationContext applicationContext; - - private SecuredBrowserlessApplicationContext app; - - @BeforeEach - void setUp() { - app = SpringBrowserlessApplicationContext.createSecured( - applicationContext, ProtectedView.class); - } - - @AfterEach - void tearDown() { - app.close(); - } - - @Test - void switchingUsers_securityContextFollowsActiveWindow() { - var admin = app.newUser("john", "ADMIN").newWindow(); - var anon = app.newUser().newWindow(); - - admin.navigate(ProtectedView.class); - Assertions.assertInstanceOf(ProtectedView.class, - admin.getCurrentView()); - - // Switching to the anonymous user restores their (empty) context; - // the protected view redirects to login. - Assertions.assertThrows(IllegalArgumentException.class, - () -> anon.navigate(ProtectedView.class)); - Assertions.assertInstanceOf(LoginView.class, anon.getCurrentView()); - - // Switching back restores admin's authentication. - admin.navigate(ProtectedView.class); - Assertions.assertInstanceOf(ProtectedView.class, - admin.getCurrentView()); - } -} ----- [methodname]`newUser(String username, String... roles)` is a convenience that produces an [classname]`Authentication` with the conventions of [annotationname]`@WithMockUser`. To install a custom [classname]`Authentication` directly, pass it to [methodname]`newUser(Authentication)`. Calling [methodname]`newUser()` without arguments creates an anonymous user; the handler installs Spring's [classname]`AnonymousAuthenticationToken`. @@ -266,53 +101,8 @@ A logout performed in one window leaves the user logged out in all of that user' == Authenticated Users with Quarkus Security -The Quarkus factory follows the same pattern with [classname]`SecurityIdentity` as the credential type: +The Quarkus factory follows the same pattern with [classname]`SecurityIdentity` as the credential type. -.Quarkus Multi-User Test -[source,java] ----- -@QuarkusTest -@TestProfile(SecurityTestConfig.class) -class MultiUserSecurityTest { - - private SecuredBrowserlessApplicationContext app; - - @BeforeEach - void setUp() { - app = QuarkusBrowserlessApplicationContext - .createSecured(ProtectedView.class); - } - - @AfterEach - void tearDown() { - app.close(); - } - - @Test - void authenticatedUser_byUsernameAndRoles_seesProtectedView() { - var window = app.newUser("john", "USER").newWindow(); - - window.navigate(ProtectedView.class); - Assertions.assertInstanceOf(ProtectedView.class, - window.getCurrentView()); - } - - @Test - void authenticatedUser_byIdentity_seesProtectedView() { - SecurityIdentity identity = QuarkusSecurityIdentity.builder() - .setPrincipal(new QuarkusPrincipal("john")) - .addRoles(Set.of("USER")) - .setAnonymous(false) - .build(); - - var window = app.newUser(identity).newWindow(); - - window.navigate(ProtectedView.class); - Assertions.assertInstanceOf(ProtectedView.class, - window.getCurrentView()); - } -} ----- As with the Spring factory, [methodname]`newUser()` without arguments creates an anonymous user, and cross-user window switches save and restore the active [classname]`SecurityIdentity` automatically. @@ -357,7 +147,7 @@ For advanced setups, [classname]`BrowserlessApplicationContext.Builder` exposes | Applies a configuration built elsewhere as the baseline that the other methods add to, such as [methodname]`BrowserlessConfiguration.from(getClass())` to reuse what the test class declares with [annotationname]`@BrowserlessTestConfig`. |=== -The property, feature flag, and configuration methods are the programmatic form of [annotationname]`@BrowserlessTestConfig`, which the context itself does not read; see <>. [classname]`SecuredBrowserlessApplicationContext.Builder` exposes the same methods. +The property, feature flag, and configuration methods are the programmatic form of [annotationname]`@BrowserlessTestConfig`, which the context itself does not read; see <>. [classname]`SecuredBrowserlessApplicationContext.Builder` exposes the same methods. For one-off tweaks without holding on to a builder reference, [methodname]`BrowserlessApplicationContext.create(UnaryOperator)` and the corresponding [methodname]`createSecured(Function>)` accept a configurer: @@ -384,5 +174,9 @@ Common gotchas worth keeping in mind: - [since:com.vaadin:vaadin@V25.2]#*Spring request and session scopes follow the user.*# A [annotationname]`@SessionScope` or [annotationname]`@RequestScope` bean is resolved against the active user's own request, so two users never share one instance. [annotationname]`@VaadinSessionScope` has always behaved this way, because it resolves against [methodname]`VaadinSession.getCurrent()`. See <> for why such a bean can't be autowired into a test class field. Resolving it from the test method instead gives the active user's instance, so activate the intended user's window first. - *Anonymous users still go through the handler.* On a secured context, [methodname]`newUser()` with no arguments delegates to the handler, which installs its anonymous-equivalent state (for example, Spring's [classname]`AnonymousAuthenticationToken`). +For a worked example, see <<{articles}/building-apps/testing/browserless/test-multiple-users#,Test Multiple Users and Windows>>. + + +For the procedures previously covered here, [[setting-up-the-application-context]]see <<{articles}/building-apps/testing/browserless/test-multiple-users#,the Building Apps guide>>. [discussion-id]`9C4E8A2D-6F31-4B72-9A8F-5D7E1C3B4F60` diff --git a/articles/flow/testing/browserless/non-spring.adoc b/articles/flow/testing/browserless/non-spring.adoc deleted file mode 100644 index e075861609..0000000000 --- a/articles/flow/testing/browserless/non-spring.adoc +++ /dev/null @@ -1,69 +0,0 @@ ---- -title: Non-Spring Projects -page-title: Browserless testing in non-Spring Vaadin projects -description: How to write browserless tests in plain Java projects without Spring. -meta-description: Set up and run browserless tests in Vaadin applications that don't use Spring or Spring Boot. -order: 40 ---- - - -= Browserless Testing in Non-Spring Projects - -If your project doesn't use Spring or Spring Boot, you can write browserless tests by extending the [classname]`BrowserlessTest` base class directly. This base class instantiates a UI along with all the necessary Vaadin environment, which is available to your test methods. - - -== Dependencies - -Add the `browserless-test-junit6` dependency with a `test` scope. Assuming you have imported the Vaadin Bill-of-Materials (BOM) and have a Maven project, all you need is: - -[source,xml] ----- - - com.vaadin - browserless-test-junit6 - test - ----- - -No other test framework dependencies are required. - - -== Writing Tests - -Create a test class that extends [classname]`BrowserlessTest`: - -[source,java] ----- -class HelloWorldViewTest extends BrowserlessTest { - - @Test - public void setText_clickButton_notificationIsShown() { - final HelloWorldView helloView = navigate(HelloWorldView.class); - - test(helloView.name).setValue("Test"); - test(helloView.sayHello).click(); - - Notification notification = find(Notification.class).single(); - Assertions.assertEquals("Hello Test", test(notification).getText()); - } - -} ----- - -All the features described in the <> guide — package scanning, navigation, component testing, and component queries — work the same way with [classname]`BrowserlessTest`. Replace [classname]`SpringBrowserlessTest` with [classname]`BrowserlessTest` and remove the [annotationname]`@SpringBootTest` annotation. - -.Already Have a Test Base Class? -[TIP] -If your project already has its own test base class, you can still get the Vaadin environment without extending [classname]`BrowserlessTest`. See <> for the composition-based setup. - -.Multiple Users or Windows? -[TIP] -This setup drives a single user with a single window. For tests that need several concurrent users or multiple windows of the same user, see <>. - - -== Running Tests - -Testing with [classname]`BrowserlessTest` doesn't require any particular setup. Run the test directly from your IDE or use Maven, for example by typing `mvn test` in the terminal. - - -[discussion-id]`D68CAC9E-6131-45C9-84E6-6D1CA1E44E81` diff --git a/articles/flow/testing/browserless/optimizing-tests.adoc b/articles/flow/testing/browserless/optimizing-tests.adoc deleted file mode 100644 index 4a0b765d18..0000000000 --- a/articles/flow/testing/browserless/optimizing-tests.adoc +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: Optimizing Tests -page-title: How to optimize Vaadin browserless tests for faster execution -description: Speed up browserless tests by restricting package scanning and using reduced application contexts. -meta-description: Optimize Vaadin browserless tests with package scanning restrictions and reduced Spring application contexts. -order: 85 ---- - - -= Optimizing Browserless Tests - -By default, browserless tests scan the entire classpath for routes and error views and, in Spring Boot projects, load the full application context. For large projects this can slow down test startup. The following techniques help reduce bootstrap time. - - -== Restricting Package Scanning - -To restrict the scan to specific packages and their sub-packages, annotate the test class with [annotationname]`@ViewPackages` and specify the packages by filling the [methodname]`classes()` array with classes that are members of the desired packages, or by providing the packages with fully qualified names in the [methodname]`packages()` property. Using [methodname]`classes()` is the preferred way, since it plays well with IDE refactoring when moving classes to different packages. - -.Package Scan Examples -[source,java] ----- -@SpringBootTest -@ViewPackages(classes={ MyView.class, OtherView.class }) -class MyViewTest extends SpringBrowserlessTest { -} - -@SpringBootTest -@ViewPackages(packages={ "com.example.app.pgk1", "com.example.app.pgk2" }) -class MyViewTest extends SpringBrowserlessTest { -} - -@SpringBootTest -@ViewPackages( - classes={ MyView.class, OtherView.class }, - packages={ "com.example.app.pgk1", "com.example.app.pgk2" } -) -class MyViewTest extends SpringBrowserlessTest { -} ----- - - -Using the annotation without providing [methodname]`classes()` or [methodname]`packages()` acts as a shortcut for restricting the scan to the current test class package and sub-packages. - -[source,java] ----- -@SpringBootTest -@ViewPackages // same as @ViewPackages(classes=MyViewTest.class) -class MyViewTest extends SpringBrowserlessTest { -} ----- - - -== Using a Reduced Application Context - -Instead of [annotationname]`@SpringBootTest`, which loads the full application context, you can annotate the test with [annotationname]`@ContextConfiguration` to provide only the beans needed for the test. This is useful when you want to replace real services with test doubles. - -[source,java] ----- -@ContextConfiguration(classes = ViewTestConfig.class) -class ViewTest extends SpringBrowserlessTest { - @Test - public void setText_clickButton_notificationIsShown() { - final HelloWorldView helloView = navigate(HelloWorldView.class); - - test(helloView.name).setValue("Test"); - test(helloView.sayHello).click(); - - Notification notification = find(Notification.class).single(); - Assertions.assertEquals("Hello Test", test(notification).getText()); - } -} - -@Configuration -class ViewTestConfig { - - @Bean - GreetingService myService() { - return new TestingGreetingService(); - } -} ----- - -[NOTE] -==== -Prefer replacing services this way -- a test [annotationname]`@Configuration` selected with [annotationname]`@ContextConfiguration` -- over bean overrides such as [annotationname]`@MockitoBean` or [annotationname]`@MockBean`, especially when other test classes in the same run authenticate with [annotationname]`@WithUserDetails`, [annotationname]`@WithMockUser`, or a similar annotation. - -A bean override changes the context definition, so Spring's test context cache stores a *separate* application context for that test class. In a multi-class run, the resulting context churn can leave the Vaadin and Spring Security integration in a state where the simulated user isn't applied during navigation, causing protected views to redirect to the login view in *unrelated* tests -- a confusing failure that disappears when the affected class is run on its own. - -For service-level tests that don't need the Vaadin context, an even simpler option is to construct the service directly with stub collaborators (for example `new ResourceService(repo, id -> 0L)`) instead of overriding a bean. Both approaches keep a single shared context. -==== - - -[role="since:com.vaadin:vaadin@V25.2"] -== Sharing the Vaadin Environment Across Tests - -By default, the Vaadin environment -- the session, the UI, and all routes -- is created before every test method and torn down after. For classes with many tests that navigate to views sharing the same [classname]`MainLayout`, this setup cost can dominate the test runtime. - -To reuse a single Vaadin environment across all test methods in a class, register a static [classname]`BrowserlessClassExtension` with [annotationname]`@RegisterExtension`. The extension initializes the environment once before all tests and tears it down after all tests, sharing the same [classname]`UI` instance across every method. Instead of extending [classname]`BrowserlessTest`, implement the [interfacename]`TesterWrappers` and [interfacename]`Locators` interfaces to use the tester and locator DSL directly, and use the extension instance for navigation. This can significantly reduce runtime for suites with hundreds of tests on the same view. - -.Shared Environment Example -[source,java] ----- -@ViewPackages(classes = CartView.class) -class CartViewTest implements TesterWrappers, Locators { - - @RegisterExtension - static BrowserlessClassExtension extension = new BrowserlessClassExtension(); - - @BeforeAll - static void setup() { - extension.navigate(CartView.class); - } - - @Test - void addItem_increasesCartSize() { - // same UI instance as the other tests - findButton().withText("Add").click(); - } - - @Test - void removeItem_decreasesCartSize() { - // state from the previous test is preserved - } -} ----- - -[WARNING] -With a shared environment, state leaks between tests. Tests must either tolerate leftover state or reset it explicitly -- for example, by re-navigating to the view in a [annotationname]`@BeforeEach` method. Prefer a shared environment for read-only or independent interactions; stick with the default per-method lifecycle when tests mutate shared state in conflicting ways. - -[NOTE] -The base test classes -- [classname]`BrowserlessTest`, [classname]`SpringBrowserlessTest`, and [classname]`QuarkusBrowserlessTest` -- always reinitialize the Vaadin environment before each test method. Annotating them with [annotationname]`@TestInstance(PER_CLASS)` therefore does not share the Vaadin environment, although it can still be useful for sharing other per-class state. Use a static [classname]`BrowserlessClassExtension` as shown above to share the Vaadin environment. - - -[discussion-id]`A3B7E2F1-5D89-4C6A-9E12-7F4A8B3C6D50` diff --git a/articles/flow/testing/browserless/overlay-components.adoc b/articles/flow/testing/browserless/overlay-components.adoc index f4dabda093..81bd78502d 100644 --- a/articles/flow/testing/browserless/overlay-components.adoc +++ b/articles/flow/testing/browserless/overlay-components.adoc @@ -9,7 +9,8 @@ order: 25 = Testing Overlay Components -Some Vaadin components render their content in overlays, which means their child components aren't part of the normal view component tree. Standard top-level component queries ([methodname]`find()`) can't reach components inside these overlays. Instead, use the component-specific testers to interact with overlay content. +Some Vaadin components render their content in overlays, which means their child components aren't part of the normal view component tree. While the overlay is closed, top-level component queries ([methodname]`find()`) cannot reach its detached contents. +Opening the overlay attaches that content so a top-level query can find it. Instead, use the component-specific testers to interact with overlay content. == Context Menu @@ -147,11 +148,11 @@ A tester obtained with [methodname]`test(gridContextMenu)` targets no row, so it === Common Pitfalls -Using a top-level [methodname]`find()` to search for components inside a context menu doesn't work because the overlay content isn't part of the main component tree. Use [methodname]`test(contextMenu).find()` or [methodname]`test(contextMenu).clickItem()` instead. +Using a top-level [methodname]`find()` to search inside a closed context menu returns no results because its content is detached. Use [methodname]`test(contextMenu).find()` or [methodname]`test(contextMenu).clickItem()` instead. [source,java] ---- -// This does NOT work -- won't find items inside the context menu +// While the menu is closed, this does not find its items Button menuButton = find(Button.class).withText("My Action").single(); // throws // Use the tester's find() method instead @@ -185,4 +186,6 @@ List fileItems = menuBar_.getItemTexts("File"); A [classname]`MenuBar` is always visible, so its items need no opening. [since:com.vaadin:vaadin@V25.3]#[methodname]`getItemTexts()`# leaves out hidden items, which are also skipped when an item is looked up by text or position. +For a worked example, see <<{articles}/building-apps/testing/browserless/test-user-interactions#,Test User Interactions>>. + [discussion-id]`E3F7D8A2-9B14-4C6E-A1D0-8F5E2C3B7A91` diff --git a/articles/flow/testing/browserless/quarkus.adoc b/articles/flow/testing/browserless/quarkus.adoc index f533bf5078..b21301c7bb 100644 --- a/articles/flow/testing/browserless/quarkus.adoc +++ b/articles/flow/testing/browserless/quarkus.adoc @@ -1,183 +1,38 @@ --- -title: Quarkus-based Projects -page-title: How to run browserless tests in Quarkus-based Projects | Vaadin -description: How to write browserless tests in Quarkus-based projects. +title: Quarkus Integration +page-title: Quarkus Integration | Vaadin +description: Quarkus browserless test initialization, dependency injection, test profiles, and security integration. +meta-description: Quarkus browserless test initialization, dependency injection, test profiles, and security integration. order: 50 --- -= Browserless Testing in Quarkus-based Projects += Quarkus Integration -In Quarkus-based projects, views may use dependency injection to get references to service and other software components. To instantiate such views and correctly handle navigation, Vaadin needs special implementations of internal components, such as [classname]`QuarkusInstantiator`. Browserless testing provides a specialized base test class that integrates with the Quarkus Testing Framework: [classname]`QuarkusBrowserlessTest`. +`QuarkusBrowserlessTest` integrates with `@QuarkusTest` and uses Quarkus-specific instantiation for dependency injection into views. +Tests run in a mocked Vaadin environment even though `@QuarkusTest` also starts the application and HTTP server. -Subclasses can therefore rely on all of the features offered by the Quarkus Testing Framework by being annotated with [annotationname]`@QuarkusTest`. See https://quarkus.io/guides/getting-started-testing[Quarkus Testing documentation] for additional information about Quarkus testing framework. +== Test Profiles -.Quarkus Test Example -[source,java] ----- -@QuarkusTest -class ViewTest extends QuarkusBrowserlessTest { - @Test - public void setText_clickButton_notificationIsShown() { - final HelloWorldView helloView = navigate(HelloWorldView.class); +`@TestProfile` selects a `QuarkusTestProfile` that can override configuration, enable alternative beans, and supply test resources. - test(helloView.name).setValue("Test"); - test(helloView.sayHello).click(); +== Navigation Access Control - Notification notification = find(Notification.class).single(); - Assertions.assertEquals("Hello Test", test(notification).getText()); - } -} ----- +The Quarkus integration requires explicit registration of `NavigationAccessControl` as a UI `BeforeEnterListener`. +A service-init observer can register it for the mocked service. +Restricting that observer to `MockQuarkusServletService` avoids applying test-only setup to the application also started by `@QuarkusTest`. -[NOTE] -With [annotationname]`@QuarkusTest` annotation, the testing framework starts the application and the HTTP server -- although it won't be required for browserless testing. However, [classname]`QuarkusBrowserlessTest` tests are still executed in a mocked environment. +== Security Integration -A test can be annotated with [annotationname]`@TestProfile` to reference a specific test configuration. With a test profile you can, for example, override application configuration, provide bean alternatives and custom test resources. Refer to the https://quarkus.io/guides/getting-started-testing#testing_different_profiles[Quarkus Testing Profiles documentation] for additional information. +When Quarkus Security is present, the mock environment obtains authentication from `SecurityIdentity` before UI creation and initial navigation. +`@TestSecurity` supplies test-method authentication and requires `quarkus-test-security`. +For simultaneous users, <> maintain each user's security identity separately. -.Quarkus Testing Profile Example -[source,java] ----- -public class MockServiceProfile implements QuarkusTestProfile { +For a worked example, see <<{articles}/building-apps/testing/browserless/setup-quarkus#,Set Up Browserless Tests with Quarkus>>. - @Override - public Map getConfigOverrides() { - return Collections.singletonMap("app.some.config","value"); - } +For a worked example, see <<{articles}/building-apps/testing/browserless/test-view-access#,Test View Access Control>>. - @Override - public Set> getEnabledAlternatives() { - return Collections.singleton(MockService.class); - } -} - -@QuarkusTest -@TestProfile(MockServiceProfile.class) -class ViewTest extends QuarkusBrowserlessTest { -} ----- - - -== Additional Setup - -In addition to <>, be sure to add the Quarkus browserless testing and the Quarkus test dependencies to your project. Add the following to your [filename]`pom.xml` file: - -.pom.xml -[source,xml] ----- - - com.vaadin - browserless-test-quarkus - test - - - io.quarkus - quarkus-junit5 - test - ----- - - -== Set Up View Access Control - -To apply view access control, Vaadin requires a [classname]`NavigationAccessControl` to be registered as a [classname]`BeforeEnterListener` for the UI. Currently, the Vaadin Quarkus plugin doesn't support automatic registration of the access control feature. To enable it for browserless testing, perform the setup in a [classname]`QuarkusTestProfile` class by providing an observer for the Vaadin [classname]`ServiceInitEvent` that executes this step. - -.NavigationAccessControl for Quarkus Project Test -[source,java] ----- -public class TestViewSecurityConfig implements QuarkusTestProfile { - - @Override - public String getConfigProfile() { - return "test-security"; // <1> - } - - @IfBuildProfile("test-security") // <1> - public static class NavigationAccessControlInitializer { - - public void serviceInit(@Observes ServiceInitEvent event) { // <2> - // @QuarkusTest starts the whole application, so we check - // the VaadinService type to enable access control only for - // browserless tests - if (event.getSource() instanceof MockQuarkusServletService) { // <3> - event.getSource().addUIInitListener(uiEvent -> { - // Customize the NavigationAccessControl as needed - NavigationAccessControl accessControl = new NavigationAccessControl(); - accessControl.setLoginView(LoginView.class); - - uiEvent.getUI().addBeforeEnterListener(accessControl); - }); - } - } - } -} ----- -<1> Sets the configuration profile to be used for the test. The class is annotated with [annotationname]`@IfBuildProfile` to make the observer only run it for tests that require this profile. -<2> Listens for Vaadin [classname]`ServiceInitEvent`. This is the same as implementing [classname]`VaadinServiceInitListener` and registering the class to be loaded by Java [classname]`ServiceLoader`. -<3> Checks that execution is started by the browserless test. This is required because [annotationname]`@QuarkusTest` causes the whole application to start when running the test. - -== Quarkus Test Security Features - -When using [classname]`QuarkusBrowserlessTest`, if Quarkus Security is present on the classpath, the mock environment is instructed to fetch authentication details from Quarkus [classname]`SecurityIdentity`. - -With this support, you can use Quarkus [annotationname]`@TestSecurity` annotation to simulate different authentication scenarios with test method granularity. More information is available from the https://quarkus.io/guides/security-testing[Quarkus Security Testing documentation]. Authentication details are available before creating the UI instance and navigating to the default route. 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. - -To use Quarkus Security test annotations, first ensure the dependency is added to the project: - -[source,xml] ----- - - io.quarkus - quarkus-test-security - test - ----- - -Next, extend [classname]`QuarkusBrowserlessTest` and annotate test methods to set up an authentication scenario. For the simplest situations, use [annotationname]`@TestSecurity`, providing the username and roles that should be granted. - -.Tests with Mock Users -[source,java] ----- -@QuarkusTest -@TestProfile(TestViewSecurityConfig.class) // <1> -class ViewSecurityTest extends QuarkusBrowserlessTest { - - @Test - @TestSecurity(authorizationEnabled = false) // <2> - void anonymousUser_protectedView_redirectToLogin() { - navigate("protected", LoginView.class); - } - - @Test - @TestSecurity(authorizationEnabled = false) // <2> - void anonymousUser_publicView_signInLinkPresent() { - // public view is default page - Assertions.assertInstanceOf(PublicView.class, getCurrentView()); - - Anchor anchor = find(Anchor.class).withText("Sign in").single(); - Assertions.assertTrue( - test(anchor).isUsable(), - "Sign in link should be available for anonymous user"); - } - - @Test - @TestSecurity(user = "admin", roles = "ADMIN") // <2> - void adminUser_adminView_viewShown() { - navigate(AdminRoleView.class); - - Assertions.assertTrue( - find(Avatar.class).single().isVisible(), - "Avatar should be visible for logged users"); - } -} ----- -<1> Sets a profile to activate Vaadin access control feature. -<2> Uses Quarkus test security annotations. - - -.Multiple Users or Windows? -[TIP] -This setup drives a single user with a single window. For tests that need several concurrent users or multiple windows of the same user, see <>. +For the procedures previously covered here, [[additional-setup]][[quarkus-test-security-features]][[set-up-view-access-control]]see <<{articles}/building-apps/testing/browserless/setup-quarkus#,the Building Apps guide>>. [discussion-id]`61B2F8E5-448E-4C36-82E3-D492712ECE67` diff --git a/articles/flow/testing/browserless/snapshots.adoc b/articles/flow/testing/browserless/snapshots.adoc index 7a19fcddbd..c64d56f4e0 100644 --- a/articles/flow/testing/browserless/snapshots.adoc +++ b/articles/flow/testing/browserless/snapshots.adoc @@ -1,31 +1,18 @@ --- -title: Snapshots -page-title: How to use snapshots in Vaadin browserless testing -description: Use UI snapshots to debug failing browserless tests. -meta-description: Learn how to enable and use UI snapshots to debug failing Vaadin browserless tests by inspecting the component tree. +title: UI Snapshots +page-title: UI Snapshots | Vaadin +description: Failure snapshot extension behavior and the format of server-side component tree output. +meta-description: Failure snapshot extension behavior and the format of server-side component tree output. order: 30 --- -= Debugging with UI Snapshots - -When a browserless test fails, it can be hard to tell why. The assertion message might say a component wasn't found or had an unexpected value, but it doesn't show you what the UI _actually_ looked like. UI snapshots solve this by printing a text representation of the entire component tree at the moment of failure, so you can see exactly what was on screen. - - -== Enabling Snapshots - -Snapshots aren't enabled by default. Add the [annotationname]`@ExtendWith(TreeOnFailureExtension.class)` annotation to your test class: - -[source,java] ----- -@ExtendWith(TreeOnFailureExtension.class) -class HelloWorldViewTest extends BrowserlessTest { - ... -} ----- - -When any test in the class fails, the extension automatically prints the UI tree to the test output alongside the failure message. += UI Snapshots +`TreeOnFailureExtension` prints a text representation of the server-side UI component tree when a test fails. +Snapshots are disabled by default and enabled with `@ExtendWith(TreeOnFailureExtension.class)`. +The output appears alongside the test failure message. +It represents Java component state and element attributes, not a browser screenshot or rendered layout. == Reading a Snapshot @@ -45,69 +32,9 @@ Each line represents a component. The information in brackets tells you about it - **Attributes** -- prefixed with `@` (e.g., `@class`, `@style`, `@theme`). These are the HTML element attributes set on the component. - **Nesting** -- the tree structure shows parent-child relationships, matching how components are added to layouts in your code. +For a worked example, see <<{articles}/building-apps/testing/browserless/debug-tests#,Debug a Failing Browserless Test>>. -== Using Snapshots to Debug Failures - -Suppose you have a view like this: - -[source,java] ----- -@Route("") -public class HelloWorldView extends HorizontalLayout { - - TextField name; - Button sayHello; - - public HelloWorldView() { - name = new TextField("Your name"); - sayHello = new Button("Say hello"); - sayHello.addClickListener(e -> { - if (!name.getValue().isEmpty()) { - Notification.show("Hello " + name.getValue()); - } - }); - add(name, sayHello); - } -} ----- - -And a test for it: - -[source,java] ----- -@Test -public void clickSayHello_showsGreeting() { - HelloWorldView view = navigate(HelloWorldView.class); - test(view.sayHello).click(); - Notification notification = find(Notification.class).single(); - assertEquals("Hello World", test(notification).getText()); -} ----- - -The test fails because no [classname]`Notification` was found. The assertion error alone doesn't explain why. With snapshots enabled, the test output includes the UI tree: - ----- -└── UI[] - └── HelloWorldView[@theme='margin spacing'] - ├── TextField[label='Your name', value=''] - └── Button[caption='Say hello'] ----- - -Now you can see: - -- There's no [classname]`Notification` in the tree -- the click didn't produce one. -- The [classname]`TextField` has `value=''` -- the name field is empty. -- Looking back at the view code, the greeting is `"Hello " + name.getValue()`. The test forgot to set a name first, and an empty greeting might have been suppressed or the logic depends on a non-empty name. - -Without the snapshot, you'd have to guess. With it, the empty `value=''` points you straight to the problem. - - -== Tips - -- **Look for what's missing.** If a query like `find(Notification.class).single()` fails, the snapshot shows you that the component simply isn't there. Check the tree for clues about why it wasn't created. -- **Check property values.** When an assertion on a component's text or value fails, find that component in the tree and compare its actual properties to what you expected. -- **Watch for unexpected components.** If `find(Button.class).single()` fails because multiple buttons were found, the snapshot shows you all of them so you can narrow your query. -- **Inspect the layout hierarchy.** If a component appears in the tree but a scoped query like `findInView(TextField.class)` can't find it, the snapshot helps you see whether the component is nested inside the expected parent. +For the procedures previously covered here, [[enabling-snapshots]][[tips]][[using-snapshots-to-debug-failures]]see <<{articles}/building-apps/testing/browserless/debug-tests#,the Building Apps guide>>. [discussion-id]`99487CB3-54AB-4C0A-8A46-926A527EB381` diff --git a/articles/flow/testing/browserless/spring-security.adoc b/articles/flow/testing/browserless/spring-security.adoc index 59966f8afe..54306c4104 100644 --- a/articles/flow/testing/browserless/spring-security.adoc +++ b/articles/flow/testing/browserless/spring-security.adoc @@ -1,185 +1,45 @@ --- -title: Spring Security Testing -page-title: Browserless testing with Spring Security in Vaadin -description: How to test view access control and Spring Security integration in browserless tests. -meta-description: Test Vaadin view access control and Spring Security annotations in browserless tests. +title: Spring Security Integration +page-title: Spring Security Integration | Vaadin +description: Authentication timing and navigation access-control requirements for Spring browserless tests. +meta-description: Authentication timing and navigation access-control requirements for Spring browserless tests. order: 28 --- -= Browserless Testing with Spring Security += Spring Security Integration -Vaadin comes with built-in security helpers that enable annotation-based view access control, which integrates well with Spring Security. When using [classname]`SpringBrowserlessTest`, if Spring Security is present on the classpath, the mock environment is instructed to fetch authentication details from Spring [classname]`SecurityContextHolder`. +`SpringBrowserlessTest` integrates the simulated Vaadin environment with Spring Security test authentication. +By default, authentication from Spring Security test annotations is available before UI creation and initial navigation, so route access checks observe the test user. +== Navigation Access Control -== Set Up View Access Control +`NavigationAccessControl` must be registered as a UI `BeforeEnterListener` for view protection to apply. +Spring Boot tests normally receive this setup automatically. +A restricted Spring context may need explicit registration through a `VaadinServiceInitListener`, or a `NavigationAccessControl` bean together with `NavigationAccessControlInitializer`. -To apply view access control, Vaadin requires a [classname]`NavigationAccessControl` to be registered as a [classname]`BeforeEnterListener` for the UI. For [annotationname]`@SpringBootTest` annotated tests, the checker is created and configured automatically. However, when testing with a restricted `ApplicationContext`, you may want to perform the setup yourself in a [classname]`Configuration` class by providing a [classname]`VaadinServiceInitListener` that executes this step. +== Authentication Sources -.Set Up NavigationAccessControl for Plain Spring Project -[source,java] ----- -@Configuration -class TestViewSecurityConfig { - - @Bean - VaadinServiceInitListener setupViewSecurityScenario() { - SpringNavigationAccessControl accessControl = new SpringNavigationAccessControl(); - accessControl.setLoginView(LoginView.class); - return event -> { - event.getSource().addUIInitListener(uiEvent -> { - uiEvent.getUI().addBeforeEnterListener(accessControl); - }); - }; - } -} ----- - -If you're using the Vaadin Spring Add-On, you can instead import the out-of-the-box [classname]`NavigationAccessControlInitializer`. It requires only that you define a [classname]`NavigationAccessControl` bean. - -.Set Up NavigationAccessControl with Vaadin Spring Add-On -[source,java] ----- -@Configuration -@Import({NavigationAccessControlInitializer.class}) -class TestViewSecurityConfig { - - @Bean - NavigationAccessControl navigationAccessControl() { - return new SpringNavigationAccessControl(); - } -} ----- - -== Testing with Spring Security Annotations - -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 <>. +Spring Security test annotations such as `@WithMockUser`, `@WithAnonymousUser`, and `@WithUserDetails` select authentication for individual test methods. +They require `spring-security-test` on the test classpath. +`@WithUserDetails` uses a `UserDetailsService` from the selected application context. -To use Spring Security test annotations, first make sure the dependency is added to the project. +Authentication established in the test method, or with `setupBefore = TestExecutionEvent.TEST_EXECUTION`, arrives after initial navigation. +The rendered view is not replaced automatically; another navigation applies access control to the new authentication. +See <> for the lifecycle contract and the linked test example. -[source,xml] ----- - - org.springframework.security - spring-security-test - test - ----- - -[CAUTION] -Overriding beans with [annotationname]`@MockitoBean` or [annotationname]`@MockBean` makes Spring cache a separate application context for that test class. In a multi-class run, this can prevent the simulated user from being applied during navigation, causing protected views to redirect unexpectedly to the login view -- sometimes in *other* test classes. If security navigation tests fail only when the whole suite runs, suspect a bean override elsewhere. See <> for a context-friendly way to replace services. +== Application Context Isolation -Then extend [classname]`SpringBrowserlessTest` and annotate test methods to set up an authentication scenario. For the simplest use cases, use [annotationname]`@WithMockUser` or [annotationname]`@WithAnonymousUser`, providing the username and roles that should be granted. +Bean overrides such as `@MockitoBean` and `@MockBean` change Spring's test context cache key. +When multiple contexts are used in a suite, the browserless security integration can fail to apply the simulated user during navigation, including in other test classes. +For a practical service-substitution approach, see <<{articles}/building-apps/testing/browserless/speed-up-tests#using-a-reduced-application-context,Using a Reduced Application Context>>. -.Tests with Mock Users -[source,java] ----- -@SpringBootTest -public class ViewSecurityTest extends SpringBrowserlessTest { - - @Test - @WithAnonymousUser - void anonymousUser_protectedView_redirectToLogin() { - navigate("protected", LoginView.class); - } - - @Test - @WithAnonymousUser - void anonymousUser_publicView_signInLinkPresent() { - // public view is default page - Assertions.assertInstanceOf(PublicView.class, getCurrentView()); - - Anchor anchor = find(Anchor.class).withText("Sign in").single(); - Assertions.assertTrue( - test(anchor).isUsable(), - "Sign in link should be available for anonymous user"); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - void adminUser_adminView_viewShown() { - navigate(AdminRoleView.class); - - Assertions.assertTrue( - find(Avatar.class).single().isVisible(), - "Avatar should be visible for logged users"); - } -} ----- - -When custom User objects or complex grant rules should be used, provide a custom [classname]`UserDetailsService` and annotate the test method with [annotationname]`@WithUserDetails`. - -.Tests with Mock UserDetailsService -[source,java] ----- -@ContextConfiguration(classes = SecurityTestConfig.class) -class SpringUnitSecurityTest extends SpringBrowserlessTest { - - @Test - @WithUserDetails("admin") - void superuser_adminView_viewShown() { - navigate(AdminRoleView.class); - - Assertions.assertTrue( - find(Avatar.class).single().isVisible(), - "Avatar should be visible for logged users"); - } - - @Test - @WithUserDetails - void user_adminView_accessDenied() { - RouteNotFoundError errorView = navigate("admin-role", - RouteNotFoundError.class); - Assertions.assertTrue( - errorView.getElement().getChild(0).getOuterHTML() - .contains("Reason: Access denied"), - "Admin view should be accessible only by users with ADMIN role"); - } - - -} - -@Configuration -class SecurityTestConfig { - - @Bean - UserDetailsService mockUserDetailsService() { - - return new UserDetailsService() { - @Override - public UserDetails loadUserByUsername(String username) - throws UsernameNotFoundException { - if ("user".equals(username)) { - return new User(username, UUID.randomUUID().toString(), - List.of( - new SimpleGrantedAuthority("ROLE_DEV"), - new SimpleGrantedAuthority("ROLE_USER") - )); - } - if ("admin".equals(username)) { - return new User(username, UUID.randomUUID().toString(), - List.of( - new SimpleGrantedAuthority("ROLE_SUPERUSER"), - new SimpleGrantedAuthority("ROLE_ADMIN") - )); - } - throw new UsernameNotFoundException( - "User " + username + " not exists"); - } - }; - } -} ----- +For simultaneous authenticated users, the <> maintains security state per user. +For a worked example, see <<{articles}/building-apps/testing/browserless/test-view-access#,Test View Access Control>>. -.Multiple Users or Windows? -[TIP] -This setup drives a single user with a single window. For tests that need several concurrent users or multiple windows of the same user, see <>. +For the procedures previously covered here, [[set-up-view-access-control]][[testing-with-spring-security-annotations]]see <<{articles}/building-apps/testing/browserless/test-view-access#,the Building Apps guide>>. [role="since:com.vaadin:vaadin@V25.3"] == Session Fixation Protection diff --git a/articles/flow/testing/browserless/test-configuration.adoc b/articles/flow/testing/browserless/test-configuration.adoc index b90b9f91d7..98e8d62cd8 100644 --- a/articles/flow/testing/browserless/test-configuration.adoc +++ b/articles/flow/testing/browserless/test-configuration.adoc @@ -9,30 +9,14 @@ order: 46 = [since:com.vaadin:vaadin@V25.3]#Test Configuration# -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 a single test method, with [annotationname]`@BrowserlessTestConfig`: +`@BrowserlessTestConfig` declares Vaadin application properties, feature flags, and Lookup services for the standard browserless environment. +Each configuration belongs to the environment created for its test and is discarded with that environment. +This is Vaadin test configuration, not a replacement for Spring, Quarkus, or CDI bean configuration. -[source,java] ----- -@ViewPackages(classes = CartView.class) -@BrowserlessTestConfig( - applicationProperties = "devmode.sessionSerialization.enabled=true", - featureFlags = "myExperimentalFeature") -class CartViewTest extends BrowserlessTest { - - @Test - void experimentalCheckoutIsShown() { - // The feature flag is enabled for this test - } - - @Test - @BrowserlessTestConfig(featureFlags = "myExperimentalFeature=false") - void fallbackCheckoutIsShown() { - // The same class, with the flag off for this method only - } -} ----- +The custom <> in this documentation calls `MockVaadin.setup()` directly and does not pass a `BrowserlessConfiguration` to it. +Adding the annotation alone to that custom setup does not apply these settings. -Each setting applies to the Vaadin environment created for the annotated test, so there is nothing to reset afterwards, and nothing leaks into the next test. +For a step-by-step example, see <<{articles}/building-apps/testing/browserless/configure-tests#,Configure a Browserless Test>>. == Settings @@ -56,7 +40,7 @@ The `browserless` application property itself stays enforced and cannot be overr == Merging Class and Method Configuration -Every annotation a test inherits contributes to the configuration, rather than being shadowed by the nearest one. The merge works entry by entry: a property name or feature identifier declared in more than one place takes the value of the highest-ranking declaration, while the names declared only once all apply. The closer a declaration is to the test method, the higher it ranks: the method first, then the test class, then superclasses from the nearest up, and then, for a [annotationname]`@Nested` test, enclosing classes from the innermost out. In the example at the top of this page, the method-level `myExperimentalFeature=false` therefore replaces the value the test class declares for that flag, and leaves `devmode.sessionSerialization.enabled` untouched. +Every annotation a test inherits contributes to the configuration, rather than being shadowed by the nearest one. The merge works entry by entry: a property name or feature identifier declared in more than one place takes the value of the highest-ranking declaration, while the names declared only once all apply. The closer a declaration is to the test method, the higher it ranks: the method first, then the test class, then superclasses from the nearest up, and then, for a [annotationname]`@Nested` test, enclosing classes from the innermost out. A method-level feature setting replaces the class-level value for that identifier without removing unrelated application properties. [source,java] ---- @@ -64,9 +48,9 @@ Every annotation a test inherits contributes to the configuration, rather than b abstract class AbstractViewTest extends BrowserlessTest { } -@BrowserlessTestConfig(featureFlags = "myExperimentalFeature") +@BrowserlessTestConfig(featureFlags = "defaultAutoResponsiveFormLayout") class CartViewTest extends AbstractViewTest { - // Both base.property and myExperimentalFeature apply + // Both base.property and defaultAutoResponsiveFormLayout apply } ---- @@ -75,7 +59,7 @@ Lookup services are the exception to the ranking: they have no name to resolve, A method-level annotation needs an environment built for each test method. When one environment is shared by the whole class, as with [classname]`BrowserlessClassExtension`, the annotation is rejected with an error naming the methods that carry it. Move it to the test class in that case. -== Configuring Without Annotations +== Configuring without Annotations The same settings can be built in code. On a JUnit 6 extension: @@ -84,7 +68,7 @@ The same settings can be built in code. On a JUnit 6 extension: @RegisterExtension BrowserlessExtension extension = new BrowserlessExtension() .withApplicationProperty("devmode.sessionSerialization.enabled", "true") - .withFeatureFlags("myExperimentalFeature"); + .withFeatureFlags("defaultAutoResponsiveFormLayout"); ---- On the application context builder of a multi-user test: @@ -93,7 +77,7 @@ On the application context builder of a multi-user test: ---- try (var app = BrowserlessApplicationContext.create(builder -> builder .withViewPackages(CartView.class) - .withFeatureFlags("myExperimentalFeature"))) { + .withFeatureFlags("defaultAutoResponsiveFormLayout"))) { // ... } ---- @@ -119,7 +103,7 @@ Or by overriding [methodname]`testConfiguration()` on a test that extends a base protected BrowserlessConfiguration testConfiguration() { return BrowserlessConfiguration.builder() .withConfiguration(super.testConfiguration()) - .withFeatureFlags("myExperimentalFeature") + .withFeatureFlags("defaultAutoResponsiveFormLayout") .build(); } ---- diff --git a/articles/flow/testing/browserless/test-environment.adoc b/articles/flow/testing/browserless/test-environment.adoc new file mode 100644 index 0000000000..a9430d18b9 --- /dev/null +++ b/articles/flow/testing/browserless/test-environment.adoc @@ -0,0 +1,114 @@ +--- +title: Test Environment and Lifecycle +page-title: Test Environment and Lifecycle | Vaadin +description: Browserless test base classes, environment lifecycle, route scanning, navigation, and simulated server round trips. +meta-description: Browserless test base classes, environment lifecycle, route scanning, navigation, and simulated server round trips. +order: 10 +--- + + += Test Environment and Lifecycle + +Browserless tests create the Vaadin service, session, and UI in the test JVM. +The standard base-class setup initializes a new environment before each test method and tears it down afterwards. +Custom setup, such as <>, replaces initialization while retaining the JUnit lifecycle hooks. +JUnit's `@TestInstance(PER_CLASS)` changes the test-instance lifecycle; it does not share the Vaadin environment. + +== Base Classes and Framework Integration + +[cols="1,2"] +|=== +| Base Class | Integration +| `BrowserlessTest` | Plain Java, provided by `browserless-test-junit6`. +| An application-owned CDI subclass of `BrowserlessTest` | Weld and `CdiVaadinServlet`; see <>. +| `SpringBrowserlessTest` | Spring application context and dependency injection, provided by `browserless-test-spring`. +| `QuarkusBrowserlessTest` | Quarkus test framework and dependency injection, provided by `browserless-test-quarkus`. +|=== + +The <> provide composition-based setup for plain Java tests. +The <> supports multiple users and windows. + +[#route-scanning] +== Route Scanning + +By default, browserless tests scan the classpath for routes and error views. +`@ViewPackages(classes = MyView.class)` restricts scanning to the packages of the supplied classes and their sub-packages. +`@ViewPackages(packages = "com.example.views")` accepts package names instead. +Both attributes may be combined. +An empty `@ViewPackages` restricts scanning to the test class's package and its sub-packages. + +In Spring tests, `@SpringBootTest` loads the full application context; `@ContextConfiguration` selects a narrower configuration. +See <> for access-control initialization when using a reduced context. + +== Navigating to Views + +With the default environment setup, the loaded view is the root view. +The custom CDI setup registers routes after initialization and navigates explicitly in the test. + +To navigate to another registered view, use the [methodname]`navigate()` methods provided by the base class: + +- For a normal view with only a path defined ++ +[methodname]`navigate(MyView.class)` ++ +[methodname]`navigate("myView", MyView.class)` +- For a view with [interfacename]`HasUrlParameter` ++ +[methodname]`navigate(MyParam.class, "parameter")` ++ +[methodname]`navigate("myParam/parameter", MyParam.class)` +- For a view with URL template `@Route("template/:param")` ++ +[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. + +[NOTE] +Navigation by location string takes in the view class, so that the initialized view can be automatically validated to be the expected one. + +You can also retrieve the current view at any time with [methodname]`getCurrentView()`: + +[source,java] +---- +HasElement view = getCurrentView(); +---- + + +== Simulating Keyboard Shortcuts + +Use [methodname]`fireShortcut()` to simulate keyboard shortcuts registered with Vaadin's shortcut API: + +[source,java] +---- +// Simulate pressing Enter +fireShortcut(Key.ENTER); + +// Simulate Ctrl+S +fireShortcut(Key.KEY_S, KeyModifier.CONTROL); +---- + +== Server Round Trips + +`roundTrip()` processes pending client-server communication in the simulated environment. +It does not start a browser or execute client-side JavaScript. +Signal task processing is described separately in <>. + +For worked examples, choose your framework in <<{articles}/building-apps/testing/browserless#,Browserless Testing>>. + + +== Framework-Specific Timing and Configuration + +See <> for Spring injection, session-scoped beans, and authentication timing. +See <> for the custom Weld lifecycle. +For standard per-test properties, feature flags, and Lookup services, see <> and the <<{articles}/building-apps/testing/browserless/configure-tests#,configuration guide>>. + + +[discussion-id]`0262B190-4113-4A35-BD70-F727FB2F2AD5` diff --git a/articles/flow/testing/browserless/testing-signals.adoc b/articles/flow/testing/browserless/testing-signals.adoc index 162331e06d..a17ff4818b 100644 --- a/articles/flow/testing/browserless/testing-signals.adoc +++ b/articles/flow/testing/browserless/testing-signals.adoc @@ -1,283 +1,58 @@ --- -title: Testing Signals -page-title: How to test signal-based UIs in Vaadin browserless tests -description: Assert on UI built with ValueSignal, ListSignal, computed signals, effects, and component bindings in browserless tests. -meta-description: Learn how to write browserless tests for Vaadin signal-based UIs, including synchronous effect propagation and background-thread updates. +title: Signal Test Environment +page-title: Signal Test Environment | Vaadin +description: Signal propagation, background task processing, shared-signal updates, and write confirmation in browserless tests. +meta-description: Signal propagation, background task processing, shared-signal updates, and write confirmation in browserless tests. order: 35 --- -= Testing Signals - -Views built on <<{articles}/flow/ui-state#, signals>> are testable without any extra setup. When a test starts, [classname]`BrowserlessTest` registers a test [classname]`SignalEnvironment`, so signal effects run deterministically on the test thread instead of on a background thread pool. - -The practical consequence is the guarantee this page is built around: a signal change made by a simulated user action is reflected in the UI *immediately*. There's nothing to wait for and no scheduler to flush — the testers and queries you call right after the action already see the updated state. - -The one exception is a signal mutated from a background thread, which is queued rather than applied synchronously. That case is covered in <<#background-threads, Updates From Background Threads>>. += Signal Test Environment +Browserless tests register a test `SignalEnvironment` to make signal effects deterministic. +The same environment is used by the standard base-class setup, JUnit extensions, and application contexts. +A custom initialization override must call `initSignalsSupport()` to retain this behavior, as shown in <>. [#synchronous] == Synchronous Propagation -A user action that mutates a signal on the test thread runs the dependent effects and bindings before the action call returns. The two tests below assert exactly that — neither one waits or flushes anything between the action and the assertion. - - -=== ValueSignal, Computed Signal, and bindText - -This view holds a count in a [classname]`ValueSignal`, derives a label string with [methodname]`Signal.computed()`, and binds it to a [classname]`Span` with [methodname]`bindText()`: - -[source,java] ----- -@Route("counter-signal") -public class CounterSignalView extends Div { - final ValueSignal count = new ValueSignal<>(0); - final Span label = new Span(); - final NativeButton increment = - new NativeButton("Increment", e -> count.update(c -> c + 1)); - - public CounterSignalView() { - label.bindText(Signal.computed(() -> "Count: " + count.get())); - add(label, increment); - } -} ----- - -The test clicks the button and asserts the label right away: - -[source,java] ----- -@ViewPackages(classes = CounterSignalView.class) -class CounterSignalTest extends BrowserlessTest { - - @Test - void clickIncrement_labelUpdatesSynchronously() { - var view = navigate(CounterSignalView.class); - Assertions.assertEquals("Count: 0", test(view.label).getText()); - - test(view.increment).click(); - - // No waiting and no runPendingSignalsTasks() — the computed signal - // and bindText effect already ran on the test thread. - Assertions.assertEquals("Count: 1", test(view.label).getText()); - } -} ----- - - -=== ListSignal and bindChildren - -Structural changes propagate the same way. This view binds a layout's children to a [classname]`ListSignal`, rendering one [classname]`Span` per entry. Clicking the button inserts an entry: - -[source,java] ----- -@Route("tags-signal") -public class TagListView extends Div { - final ListSignal tags = new ListSignal<>(); - final VerticalLayout list = new VerticalLayout(); - final NativeButton addButton = - new NativeButton("Add tag", e -> tags.insertLast("tag")); - - public TagListView() { - list.bindChildren(tags, entry -> new Span(entry.peek())); - add(list, addButton); - } -} ----- - -The inserted child is present as soon as the click returns: - -[source,java] ----- -@ViewPackages(classes = TagListView.class) -class TagListTest extends BrowserlessTest { - - @Test - void addTag_childAppearsSynchronously() { - var view = navigate(TagListView.class); - Assertions.assertEquals(0, view.list.getComponentCount()); - - test(view.addButton).click(); - - // The bindChildren effect rebuilt the list synchronously. - Assertions.assertEquals(1, view.list.getComponentCount()); - } -} ----- - -Both tests pass without flushing anything because the mutation happens on the test thread — which is the UI thread — while the components are attached. The effect runs inline, as part of the [methodname]`set()`, [methodname]`update()`, or [methodname]`insertLast()` call. - - -== What You Can Bind - -Most signal-driven UI is wired up with the [methodname]`bind*` family rather than explicit effects. From a test's perspective, each binding is just a different property to assert on after a signal changes: - -- [methodname]`bindText(signal)` — assert with [methodname]`test(component).getText()` or [methodname]`component.getText()`. -- [methodname]`bindVisible(signal)` / [methodname]`bindEnabled(signal)` — assert visibility or enabled state; a tester's [methodname]`isUsable()` reflects both. -- [methodname]`bindValue(signal, setter)` — two-way. Mutate the signal and assert the field value, or set the field value through its tester and assert the signal with [methodname]`signal.peek()`. -- [methodname]`bindChildren(listSignal, factory)` — assert the rendered child count or the individual entries. - -This page focuses on testing. For the full binding API, see <<{articles}/flow/ui-state/building-ui#, Component Bindings>> and <<{articles}/flow/ui-state/element-bindings#, Element Bindings>>. - - -== Custom Effects - -A side effect created with [methodname]`Signal.effect()` runs under the same test environment as the bindings, so it also executes synchronously when a dependency changes on the test thread. Use this to assert behavior that isn't a simple property binding — for example, showing a notification. - -This view writes the field value into a [classname]`ValueSignal` and registers an effect that opens a [classname]`Notification` whenever the amount crosses a threshold: - -[source,java] ----- -@Route("threshold") -public class ThresholdView extends Div { - final ValueSignal amountSignal = new ValueSignal<>(0); - final TextField amount = new TextField(); - - public ThresholdView() { - amount.bindValue( - amountSignal.map(String::valueOf), - v -> amountSignal.set(Integer.parseInt(v))); - - // The effect re-runs whenever amountSignal changes. - Signal.effect(this, () -> { - if (amountSignal.get() > 100) { - Notification.show("Over limit"); - } - }); - - add(amount); - } -} ----- - -The test changes the field and asserts the notification right away: - -[source,java] ----- -@ViewPackages(classes = ThresholdView.class) -class ThresholdTest extends BrowserlessTest { - - @Test - void valueExceedsLimit_notificationShownSynchronously() { - var view = navigate(ThresholdView.class); - - test(view.amount).setValue("150"); - - Assertions.assertEquals("Over limit", - test(find(Notification.class).single()).getText()); - } -} ----- +For attached components, signal mutations on the test UI thread run dependent effects and bindings before the mutation returns. +This applies to value and list signals, computed signals, component bindings, and `Signal.effect()`. +A tester action that changes a signal therefore completes its synchronous UI updates before the next assertion. +For binding APIs, see <<{articles}/flow/ui-state/building-ui#,Component Bindings>> and <<{articles}/flow/ui-state/element-bindings#,Element Bindings>>. [#background-threads] -== Updates From Background Threads - -A signal mutated *off* the UI thread — from a service callback, a [classname]`CompletableFuture`, or another session — doesn't propagate synchronously. The test [classname]`SignalEnvironment` queues the effect instead of running it inline. Call [methodname]`runPendingSignalsTasks()` to drain the queue before asserting: - -[source,java] ----- -// The view starts asynchronous work that mutates a signal on a background thread -test(view.startBackgroundWork).click(); - -// Drain the queued signal effects, then assert -runPendingSignalsTasks(); -Assertions.assertEquals("Done", test(view.status).getText()); ----- +== Updates from Background Threads -[methodname]`runPendingSignalsTasks()` waits up to 100 milliseconds for the first pending task and then drains the queue, returning [code]`true` if any tasks were processed. Use the [methodname]`runPendingSignalsTasks(long, TimeUnit)` overload to set a different wait time for slower background work. +Mutations off the UI thread queue effects instead of applying them inline. +`runPendingSignalsTasks()` waits up to 100 milliseconds for the first pending task and then drains the queue. +It returns `true` if it processed any tasks. +The `(long, TimeUnit)` overload changes the wait time. +Processing the queue is not a guarantee that arbitrary application background work has completed. -The same method is available on the JUnit extension as [methodname]`ext.runPendingSignalsTasks()` (see <>) and on each window in multi-user tests (see <>). +The method is also available on JUnit extensions and on each `BrowserlessUIContext`. +When the calling thread holds the window's session lock, the window API temporarily releases it while waiting so background threads can enqueue tasks. +== Shared Signals -=== Shared Signals - -<<{articles}/flow/ui-state/shared-signals#, Shared signals>> -- [classname]`SharedValueSignal`, [classname]`SharedNumberSignal`, [classname]`SharedListSignal`, and the other shared types -- are the most common source of background updates in a test. A change made in one session is propagated to every other session that observes the signal, and that propagation is inherently asynchronous: the observing side sees it through a queued effect rather than inline. - -As a result, a change that an observer should react to needs the same treatment as any other off-thread mutation. After triggering the change, call [methodname]`runPendingSignalsTasks()` before asserting on the observing side. A change made and observed on the same test thread -- such as mutating a shared signal and asserting a binding on the same view -- still propagates synchronously and needs no flush. For tests that drive several sessions or windows observing one shared signal, see <>. - +Changes observed from another session require queued effect processing. +Changes made and observed on the same test UI thread propagate synchronously. +See <> for context activation requirements. [#shared-signal-writes] -==== Confirming a Write - -A write to a shared signal returns a <<{articles}/flow/ui-state/transactions#operation-results, [classname]`SignalOperation`>> that completes once the underlying signal tree has confirmed the command. The write itself is applied optimistically, so the new value is visible through [methodname]`peek()` as soon as the call returns -- while the confirmation travels through the same queue as the effects. - -This view inserts a ticket into a [classname]`SharedListSignal` and updates a status label when the write is confirmed. The result callback is delivered in the context that started the operation, so it can touch components directly: - -[source,java] ----- -@Route("tickets") -public class TicketView extends Div { - final SharedListSignal tickets = - new SharedListSignal<>(String.class); - final TextField title = new TextField("Title"); - final Span status = new Span(); - final NativeButton submit = new NativeButton("Submit"); - - public TicketView() { - submit.addClickListener(e -> submitTicket(title.getValue())); - add(title, submit, status); - } - - InsertOperation> submitTicket(String title) { - status.setText("Saving..."); - - var operation = tickets.insertLast(title); - operation.result().thenAccept(result -> status.setText( - result.successful() ? "Ticket created" : "Save failed")); - return operation; - } -} ----- - -The entry is in the list right after the click, but the status label still reads [code]`Saving...` -- the callback runs only once the queued confirmation task has been executed: - -[source,java] ----- -@ViewPackages(classes = TicketView.class) -class TicketViewTest extends BrowserlessTest { - - @Test - void submitTicket_statusUpdatesWhenWriteIsConfirmed() { - var view = navigate(TicketView.class); - test(view.title).setValue("Printer is jammed"); - - test(view.submit).click(); - - // Inserted optimistically, but not confirmed yet. - Assertions.assertEquals(1, view.tickets.peek().size()); - Assertions.assertEquals("Saving...", test(view.status).getText()); - - runPendingSignalsTasks(); - - Assertions.assertEquals("Ticket created", test(view.status).getText()); - } -} ----- - -A test that gets hold of the operation itself -- because the code under test returns it, as [methodname]`submitTicket()` does -- can assert on the confirmation directly instead of going through the UI: - -[source,java] ----- -@Test -void submitTicket_operationConfirmedAfterDrainingQueue() { - var view = navigate(TicketView.class); - - var operation = view.submitTicket("Printer is jammed"); - Assertions.assertFalse(operation.result().isDone()); - - runPendingSignalsTasks(); +== Shared-Signal Write Confirmation - Assertions.assertTrue(operation.result().join().successful()); -} ----- +A shared-signal write applies optimistically: `peek()` can expose its new value before confirmation. +The returned `SignalOperation` completes after its confirmation task is processed. +Result callbacks run in the context that initiated the operation. -[WARNING] -Don't block on the operation before draining the queue. A call such as [code]`operation.result().get(5, TimeUnit.SECONDS)` always times out, because the confirmation task can only run on the very thread that's blocked waiting for it. A timeout there means the queue hasn't been drained -- not that the write was lost. +Blocking on the operation before draining the queue prevents the confirmation task from running on the blocked test thread. +Drain the queue before awaiting or asserting the result. +For a worked example, see <<{articles}/building-apps/testing/browserless/test-signals#,Test Signal-Based Views>>. -.Quick Start and Collaborative Scenarios -[TIP] -For a brief introduction to testing signal-based views, see <>. For collaborative features where several windows or users observe the same shared signal, see <>. +For the procedures previously covered here, [[confirming-a-write]][[custom-effects]][[listsignal-and-bindchildren]][[valuesignal-computed-signal-and-bindtext]][[what-you-can-bind]]see <<{articles}/building-apps/testing/browserless/test-signals#,the Building Apps guide>>. [discussion-id]`6518E8AD-13F8-48D7-A826-289115FB9A1A` diff --git a/articles/flow/testing/ui-unit/getting-started.adoc b/articles/flow/testing/ui-unit/getting-started.adoc index 58e8a9e3c7..6071051825 100644 --- a/articles/flow/testing/ui-unit/getting-started.adoc +++ b/articles/flow/testing/ui-unit/getting-started.adoc @@ -2,4 +2,4 @@ title: Getting Started section-nav: hidden --- -This page has moved: <<../browserless/getting-started#,Getting Started with Browserless Testing>> +This page has moved: <<{articles}/building-apps/testing/browserless#,Browserless Testing>> diff --git a/articles/flow/testing/ui-unit/quarkus.adoc b/articles/flow/testing/ui-unit/quarkus.adoc index e078a404d6..b165a0e96f 100644 --- a/articles/flow/testing/ui-unit/quarkus.adoc +++ b/articles/flow/testing/ui-unit/quarkus.adoc @@ -2,4 +2,4 @@ title: Quarkus-based Projects section-nav: hidden --- -This page has moved: <<../browserless/quarkus#,Browserless Testing in Quarkus-based Projects>> +This page has moved: <<{articles}/building-apps/testing/browserless/setup-quarkus#,Browserless Testing in Quarkus-based Projects>> diff --git a/articles/flow/testing/ui-unit/spring.adoc b/articles/flow/testing/ui-unit/spring.adoc index f578f4b1f5..2e437a9b11 100644 --- a/articles/flow/testing/ui-unit/spring.adoc +++ b/articles/flow/testing/ui-unit/spring.adoc @@ -2,4 +2,4 @@ title: Non-Spring Projects section-nav: hidden --- -This page has moved: <<../browserless/non-spring#,Browserless Testing in Non-Spring Projects>> +This page has moved: <<{articles}/building-apps/testing/browserless/setup-without-spring#,Browserless Testing in Non-Spring Projects>> diff --git a/articles/upgrading/index.adoc b/articles/upgrading/index.adoc index 19f17bc336..8b129af962 100644 --- a/articles/upgrading/index.adoc +++ b/articles/upgrading/index.adoc @@ -1142,7 +1142,7 @@ A test or a client that expects a redirect for such a request has to expect `401 Vaadin 25 with Spring Boot 4 uses JUnit 6 (JUnit Platform) as the default test framework. If you have JUnit 4 tests using [classname]`UIUnit4Test` (Browserless) or [classname]`TestBenchTestCase` (End-to-End), they won't be detected or executed without adding the JUnit Vintage Engine dependency. -See <<{articles}/flow/testing/browserless/getting-started#,Getting Started with Browserless Testing>> and <<{articles}/flow/testing/end-to-end/getting-started#,Getting Started with End-to-End Testing>> for the required dependencies. +See <<{articles}/building-apps/testing/browserless#,Browserless Testing>> and <<{articles}/flow/testing/end-to-end/getting-started#,Getting Started with End-to-End Testing>> for the required dependencies. === Browserless Testing: Spring Support Moved to a Separate Artifact @@ -1157,7 +1157,7 @@ Starting with Vaadin 25.2, the Spring-specific browserless-testing classes -- [c ---- -Non-Spring projects aren't affected. See <<{articles}/flow/testing/browserless/migration#,Migrating to Browserless Testing>> for details. +Non-Spring projects aren't affected. See <<{articles}/building-apps/testing/browserless/migrate-ui-unit-tests#,Migrating to Browserless Testing>> for details. === ComponentTester Click Method