Docs

Testing Signals

Assert on UI built with ValueSignal, ListSignal, computed signals, effects, and component bindings in browserless tests.

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 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.

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

Updated