Testing Signals
Views built on signals are testable without any extra setup. When a test starts, BrowserlessTest registers a test 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 Updates From Background Threads.
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 ValueSignal, derives a label string with Signal.computed(), and binds it to a Span with bindText():
Source code
Java
@Route("counter-signal")
public class CounterSignalView extends Div {
final ValueSignal<Integer> 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 code
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 ListSignal, rendering one Span per entry. Clicking the button inserts an entry:
Source code
Java
@Route("tags-signal")
public class TagListView extends Div {
final ListSignal<String> 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 code
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 set(), update(), or insertLast() call.
What You Can Bind
Most signal-driven UI is wired up with the 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:
-
bindText(signal)— assert withtest(component).getText()orcomponent.getText(). -
bindVisible(signal)/bindEnabled(signal)— assert visibility or enabled state; a tester’sisUsable()reflects both. -
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 withsignal.peek(). -
bindChildren(listSignal, factory)— assert the rendered child count or the individual entries.
This page focuses on testing. For the full binding API, see Component Bindings and Element Bindings.
Custom Effects
A side effect created with 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 ValueSignal and registers an effect that opens a Notification whenever the amount crosses a threshold:
Source code
Java
@Route("threshold")
public class ThresholdView extends Div {
final ValueSignal<Integer> 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 code
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());
}
}Updates From Background Threads
A signal mutated off the UI thread — from a service callback, a CompletableFuture, or another session — doesn’t propagate synchronously. The test SignalEnvironment queues the effect instead of running it inline. Call runPendingSignalsTasks() to drain the queue before asserting:
Source code
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());runPendingSignalsTasks() waits up to 100 milliseconds for the first pending task and then drains the queue, returning true if any tasks were processed. Use the runPendingSignalsTasks(long, TimeUnit) overload to set a different wait time for slower background work.
The same method is available on the JUnit extension as ext.runPendingSignalsTasks() (see JUnit 6 Extensions) and on each window in multi-user tests (see Signals in Multi-User Tests).
Shared Signals
Shared signals — SharedValueSignal, SharedNumberSignal, 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 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 Signals in Multi-User Tests.
|
Tip
|
Quick Start and Collaborative Scenarios
For a brief introduction to testing signal-based views, see Testing Signal-Based UIs. For collaborative features where several windows or users observe the same shared signal, see Signals in Multi-User Tests.
|
6518E8AD-13F8-48D7-A826-289115FB9A1A