Test Signal-Based Views
- Assert Changes After a User Action
- Assert Other Bindings
- Test a Custom Effect
- Process Updates from Background Threads
The examples use the plain Java setup; for another framework, change the base class as described in Use the Examples with Your Framework.
Put each test class in the view’s package, because the examples read package-private view fields.
The Java EE/CDI setup installs the signal test environment with its initSignalsSupport() call, so keep that call when you change the setup.
Assert Changes After a User Action
Start with synchronous interactions: trigger an action, then assert the resulting component state.
Test a Computed Label
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
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.NativeButton;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
@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
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.ViewPackages;
@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 ListSignal, rendering one Span per entry. Clicking the button inserts an entry:
Source code
Java
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.NativeButton;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.local.ListSignal;
@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
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.ViewPackages;
@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.
Assert Other Bindings
Most signal-driven UI is wired up with the bind* family rather than explicit effects. From a test’s perspective, each binding is 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.
Test a Custom Effect
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
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;
import com.vaadin.flow.signals.Signal;
import com.vaadin.flow.signals.local.ValueSignal;
@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
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.flow.component.notification.Notification;
@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());
}
}Process 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());See signal task processing for timeout and return-value semantics.
Test 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.
Confirm a Shared-Signal Write
A write to a shared signal returns a SignalOperation that completes when the write is confirmed. The write itself is applied optimistically, so the new value is visible through 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 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 code
Java
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.NativeButton;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.signals.operations.InsertOperation;
import com.vaadin.flow.signals.shared.SharedListSignal;
import com.vaadin.flow.signals.shared.SharedValueSignal;
@Route("tickets")
public class TicketView extends Div {
final SharedListSignal<String> 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<SharedValueSignal<String>> 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 Saving… — the callback runs only once the queued confirmation task has been executed:
Source code
Java
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import com.vaadin.browserless.BrowserlessTest;
import com.vaadin.browserless.ViewPackages;
@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 submitTicket() does — can assert on the confirmation directly instead of going through the UI:
Source code
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 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.
|
078DFEEA-BDF0-46A2-BD55-3ECD037448E7