Skip to content

Commit 119a056

Browse files
committed
fix: add Signals support for unit tests with controllable task flushing
Introduce a test-only TestSignalEnvironment that captures all Signals-related tasks when no VaadinSession/VaadinService is available (e.g., background threads). Tests can deterministically flush these tasks via new runPendingSignalsTasks helpers in BaseUIUnitTest. Fixes #1968
1 parent 1312331 commit 119a056

File tree

5 files changed

+430
-0
lines changed

5 files changed

+430
-0
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (C) 2000-2025 Vaadin Ltd
3+
*
4+
* This program is available under Vaadin Commercial License and Service Terms.
5+
*
6+
* See <https://vaadin.com/commercial-license-and-service-terms> for the full
7+
* license.
8+
*/
9+
10+
package com.example.base.signals;
11+
12+
import java.util.concurrent.CompletableFuture;
13+
14+
import com.vaadin.flow.component.ComponentEffect;
15+
import com.vaadin.flow.component.html.Div;
16+
import com.vaadin.flow.component.html.NativeButton;
17+
import com.vaadin.flow.component.html.Span;
18+
import com.vaadin.flow.router.Route;
19+
import com.vaadin.signals.NumberSignal;
20+
import com.vaadin.signals.Signal;
21+
22+
@Route("signals")
23+
public class SignalsView extends Div {
24+
25+
public final NativeButton incrementButton;
26+
public final NativeButton quickBackgroundTaskButton;
27+
public final NativeButton slowBackgroundTaskButton;
28+
public final Span counter;
29+
public final NumberSignal numberSignal;
30+
31+
public SignalsView() {
32+
numberSignal = new NumberSignal();
33+
Signal<String> computedSignal = numberSignal
34+
.mapIntValue(counter -> "Counter: " + counter);
35+
incrementButton = new NativeButton("Increment",
36+
ev -> numberSignal.incrementBy(1.0));
37+
counter = new Span("Counter: -");
38+
ComponentEffect.bind(counter, computedSignal, Span::setText);
39+
40+
quickBackgroundTaskButton = new NativeButton("Quick background task",
41+
event -> {
42+
CompletableFuture.runAsync(
43+
() -> numberSignal.incrementBy(10.0),
44+
CompletableFuture.delayedExecutor(100,
45+
java.util.concurrent.TimeUnit.MILLISECONDS));
46+
});
47+
slowBackgroundTaskButton = new NativeButton("Quick background task",
48+
event -> CompletableFuture.runAsync(() -> {
49+
ComponentEffect.effect(counter, () -> {
50+
try {
51+
Thread.sleep(1000);
52+
} catch (InterruptedException e) {
53+
Thread.currentThread().interrupt();
54+
throw new RuntimeException(e);
55+
}
56+
numberSignal.incrementBy(10.0);
57+
});
58+
}));
59+
60+
add(incrementButton, quickBackgroundTaskButton,
61+
slowBackgroundTaskButton, counter);
62+
}
63+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (C) 2000-2025 Vaadin Ltd
3+
*
4+
* This program is available under Vaadin Commercial License and Service Terms.
5+
*
6+
* See <https://vaadin.com/commercial-license-and-service-terms> for the full
7+
* license.
8+
*/
9+
10+
package com.vaadin.testbench.unit;
11+
12+
import java.util.concurrent.CompletableFuture;
13+
import java.util.concurrent.TimeUnit;
14+
15+
import com.example.base.signals.SignalsView;
16+
import org.junit.jupiter.api.Assertions;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.api.Timeout;
19+
20+
import com.vaadin.flow.component.ComponentEffect;
21+
import com.vaadin.flow.component.UI;
22+
23+
@ViewPackages(packages = "com.example.base.signals")
24+
@Timeout(10)
25+
public class SignalsTest extends UIUnitTest {
26+
27+
@Test
28+
void attachedComponent_triggerSignal_effectEvaluatedSynchronously() {
29+
var view = navigate(SignalsView.class);
30+
var counterTester = test(view.counter);
31+
Assertions.assertEquals("Counter: 0", counterTester.getText());
32+
33+
test(view.incrementButton).click();
34+
Assertions.assertEquals("Counter: 1", counterTester.getText());
35+
}
36+
37+
@Test
38+
void detachedComponent_triggerSignal_effectEvaluatedOnAttach() {
39+
var view = navigate(SignalsView.class);
40+
var counterTester = test(view.counter);
41+
Assertions.assertEquals("Counter: 0", counterTester.getText());
42+
view.counter.removeFromParent();
43+
Assertions.assertFalse(counterTester.isUsable());
44+
45+
test(view.incrementButton).click();
46+
Assertions.assertEquals("Counter: 0", view.counter.getText());
47+
48+
view.add(view.counter);
49+
Assertions.assertEquals("Counter: 1", view.counter.getText());
50+
}
51+
52+
@Test
53+
void detachedComponent_triggerEffect_effectEvaluatedAsynchronously() {
54+
var view = navigate(SignalsView.class);
55+
var counterTester = test(view.counter);
56+
Assertions.assertEquals("Counter: 0", counterTester.getText());
57+
UI ui = UI.getCurrent();
58+
UI.setCurrent(null);
59+
60+
ComponentEffect.effect(ui,
61+
() -> view.counter.setText("Counter: async"));
62+
runPendingSignalsTasks();
63+
64+
UI.setCurrent(ui);
65+
Assertions.assertEquals("Counter: async", view.counter.getText());
66+
}
67+
68+
@Test
69+
void attachedComponent_triggerSignalFromNonUIThread_effectEvaluatedAsynchronously() {
70+
var view = navigate(SignalsView.class);
71+
var counterTester = test(view.counter);
72+
Assertions.assertEquals("Counter: 0", counterTester.getText());
73+
CompletableFuture.runAsync(() -> {
74+
view.numberSignal.incrementBy(10.0);
75+
});
76+
runPendingSignalsTasks();
77+
Assertions.assertEquals("Counter: 10", counterTester.getText());
78+
}
79+
80+
@Test
81+
void attachedComponent_triggerSignalFromNonUIThreadThroughComponentEffect_effectEvaluatedAsynchronously() {
82+
var view = navigate(SignalsView.class);
83+
var counterTester = test(view.counter);
84+
Assertions.assertEquals("Counter: 0", counterTester.getText());
85+
test(view.quickBackgroundTaskButton).click();
86+
runPendingSignalsTasks(300, TimeUnit.MILLISECONDS);
87+
Assertions.assertEquals("Counter: 10", counterTester.getText());
88+
}
89+
90+
@Test
91+
void attachedComponent_slowEffect_effectEvaluatedAsynchronously() {
92+
var view = navigate(SignalsView.class);
93+
var counterTester = test(view.counter);
94+
Assertions.assertEquals("Counter: 0", counterTester.getText());
95+
test(view.slowBackgroundTaskButton).click();
96+
runPendingSignalsTasks();
97+
Assertions.assertEquals("Counter: 10", counterTester.getText());
98+
}
99+
100+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#
2+
# Copyright (C) 2000-2025 Vaadin Ltd
3+
#
4+
# This program is available under Vaadin Commercial License and Service Terms.
5+
#
6+
# See <https://vaadin.com/commercial-license-and-service-terms> for the full
7+
# license.
8+
#
9+
10+
com.vaadin.experimental.flowFullstackSignals=true

vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/BaseUIUnitTest.java

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Properties;
2222
import java.util.Set;
2323
import java.util.concurrent.ConcurrentHashMap;
24+
import java.util.concurrent.TimeUnit;
2425
import java.util.stream.Collectors;
2526
import java.util.stream.Stream;
2627

@@ -31,13 +32,16 @@
3132
import org.slf4j.Logger;
3233
import org.slf4j.LoggerFactory;
3334

35+
import com.vaadin.experimental.FeatureFlags;
3436
import com.vaadin.flow.component.Component;
3537
import com.vaadin.flow.component.HasElement;
3638
import com.vaadin.flow.component.Key;
3739
import com.vaadin.flow.component.KeyModifier;
3840
import com.vaadin.flow.component.UI;
3941
import com.vaadin.flow.router.HasUrlParameter;
4042
import com.vaadin.flow.router.RouteParameters;
43+
import com.vaadin.flow.server.VaadinService;
44+
import com.vaadin.flow.server.VaadinSession;
4145
import com.vaadin.pro.licensechecker.Capabilities;
4246
import com.vaadin.pro.licensechecker.Capability;
4347
import com.vaadin.pro.licensechecker.LicenseChecker;
@@ -71,6 +75,8 @@ public abstract class BaseUIUnitTest {
7175
protected static final Map<Class<?>, Class<? extends ComponentTester>> testers = new HashMap<>();
7276
protected static final Set<String> scanned = new HashSet<>();
7377

78+
private TestSignalEnvironment signalsTestEnvironment;
79+
7480
static {
7581
testers.putAll(scanForTesters("com.vaadin.flow.component"));
7682
Properties properties = new Properties();
@@ -180,6 +186,19 @@ protected static synchronized Routes discoverRoutes(
180186
protected void initVaadinEnvironment() {
181187
scanTesters();
182188
MockVaadin.setup(discoverRoutes(), MockedUI::new, lookupServices());
189+
initSignalsSupport();
190+
}
191+
192+
protected void initSignalsSupport() {
193+
VaadinService service = VaadinService.getCurrent();
194+
if (service == null) {
195+
throw new IllegalStateException(
196+
"Cannot initialize Signals support because VaadinService is not available");
197+
}
198+
if (FeatureFlags.get(service.getContext())
199+
.isEnabled(FeatureFlags.FLOW_FULLSTACK_SIGNALS.getId())) {
200+
signalsTestEnvironment = TestSignalEnvironment.register();
201+
}
183202
}
184203

185204
/**
@@ -223,6 +242,10 @@ Set<String> scanPackages() {
223242
* Tears down mocked Vaadin.
224243
*/
225244
protected void cleanVaadinEnvironment() {
245+
if (signalsTestEnvironment != null) {
246+
signalsTestEnvironment.unregister();
247+
signalsTestEnvironment = null;
248+
}
226249
MockVaadin.tearDown();
227250
}
228251

@@ -512,6 +535,70 @@ protected static void roundTrip() {
512535
.runExecutionsBeforeClientResponse();
513536
}
514537

538+
/**
539+
* Processes all pending Signals tasks with a default max wait time of 100
540+
* milliseconds. This is a convenience method for tests that need to wait
541+
* for asynchronous Signal effects to complete.
542+
*
543+
* <p>
544+
* When Signals are triggered from background threads or non-UI contexts,
545+
* their effects are enqueued to simulate asynchronous processing. This
546+
* method allows tests to flush and execute all such pending tasks
547+
* synchronously, ensuring deterministic behavior in unit tests.
548+
*
549+
* <p>
550+
* If any {@link VaadinSession} lock is held by the current thread, it is
551+
* temporarily released during the wait to allow background threads to
552+
* acquire the lock and enqueue tasks.
553+
*
554+
* <p>
555+
* If Signals support is not enabled (via the {@code FLOW_FULLSTACK_SIGNALS}
556+
* feature flag), this method does nothing.
557+
*
558+
* @see #runPendingSignalsTasks(long, TimeUnit)
559+
* @see TestSignalEnvironment#runPendingTasks(long, TimeUnit)
560+
*/
561+
protected final void runPendingSignalsTasks() {
562+
runPendingSignalsTasks(100, TimeUnit.MILLISECONDS);
563+
}
564+
565+
/**
566+
* Processes all pending Signals tasks, waiting up to the specified timeout
567+
* for tasks to arrive. This method is essential for testing asynchronous
568+
* Signal effects triggered from background threads or non-UI contexts.
569+
*
570+
* <p>
571+
* When Signals are triggered from background threads or non-UI contexts,
572+
* their effects are enqueued to simulate asynchronous processing. This
573+
* method allows tests to flush and execute all such pending tasks
574+
* synchronously, ensuring deterministic behavior in unit tests.
575+
*
576+
* <p>
577+
* The timeout applies only to waiting for the first task to arrive. Once
578+
* the first task is found, all remaining tasks in the queue are processed
579+
* immediately without additional waiting. If any {@link VaadinSession} lock
580+
* is held by the current thread, it is temporarily released during the wait
581+
* to allow background threads to acquire the lock and enqueue tasks.
582+
*
583+
* <p>
584+
* If Signals support is not enabled (via the {@code FLOW_FULLSTACK_SIGNALS}
585+
* feature flag), this method does nothing.
586+
*
587+
* @param maxWaitTime
588+
* the maximum time to wait for the first task to arrive in the
589+
* given time unit. If &lt;= 0, returns immediately if no tasks
590+
* are available.
591+
* @param unit
592+
* the time unit of the timeout value
593+
* @see TestSignalEnvironment#runPendingTasks(long, TimeUnit)
594+
*/
595+
protected final void runPendingSignalsTasks(long maxWaitTime,
596+
TimeUnit unit) {
597+
if (this.signalsTestEnvironment != null) {
598+
this.signalsTestEnvironment.runPendingTasks(maxWaitTime, unit);
599+
}
600+
}
601+
515602
/**
516603
* Detects the component type for the given tester from generic declaration,
517604
* by inspecting class hierarchy to resolve the concrete type for

0 commit comments

Comments
 (0)