Docs

Documentation versions (currently viewingVaadin 25)
Documentation translations (currently viewingEnglish)

Test Signal-Based Views

Assert changes to signal bindings, process background updates, and verify shared-signal write confirmation.

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 with test(component).getText() or component.getText().

  • bindVisible(signal) / bindEnabled(signal) — assert visibility or enabled state; a tester’s isUsable() 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 with signal.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