Skip to content

Testing a widget

mount() gives one widget the host it needs to exist: a real invalidate, a theme, and the layout pass that turns its state into geometry. It replaces the fake app class every widget test used to hand-roll.

from nuiitivet.testing import mount

def test_card_collapses():
    card = Card(title="hello")
    with mount(card) as host:
        host.layout(400, 200)
        assert card.preferred_size() == (400, 48)

        card.expanded.value = True     # the Observable the test holds
        host.settle()

        assert host.get(key="body").is_reachable

Under pytest, prefer the fixture — it closes the host for you, on the failing path too:

def test_card_collapses(nuiitivet_mount):
    card = Card(title="hello")
    host = nuiitivet_mount(card)
    host.layout(400, 200)

layout() is explicit

There is no default size, deliberately. A widget mounted and never laid out has no rect, and every query against it fails — which should read as "you forgot to lay it out", not as a harness bug. A default would make that failure arrive later and blame the wrong thing.

settle() before any layout() says so directly rather than settling nothing. Call layout() again to re-lay-out at a new size.

settle() after changing state

An action mutates observables; the visible effect lands on the next frame. settle() is that frame: it flushes the pending reactive work and re-lays-out, so the tree you query is the tree the change produced.

It is strict. A layout that raises reaches your test instead of a debug log, and a tree that will not converge raises rather than leaving whichever half-built frame the last pass produced.

It also pumps the zero-delay queue, so a write from a worker thread, a deferred batch flush, or a Computed's UI notify is applied. Delayed work — debounce, tooltip delays, animation ticks — stays frozen: no time has passed that your test asked for. See the harness clock for making a delayed effect actually fire.

The theme is installed, not optional

mount() installs an AppScope serving a theme, because the alternative fails silently. Theme.of falls back to the light default when it can find no scope — it does not raise — so a themeless host does not fail your test, it quietly answers the wrong theme and every style assertion passes against a default your app never runs.

mount(card)                       # the light default, AppScope installed
mount(card, theme=my_theme)       # a specific one
mount(card, scope=False)          # detached: no providers at all

scope=False is how a test asks for the detached path on purpose — a widget deliberately measured outside an App, as offscreen sizing does. It is separate from theme= because None cannot mean both "I don't mind which theme" and "install no provider"; passing both is a contradiction and raises.

To check that a widget follows a theme change rather than merely picking one up at mount:

def test_card_follows_the_theme(nuiitivet_mount):
    card = Card(None)
    host = nuiitivet_mount(card, theme=light_theme)
    host.layout(400, 200)

    host.push_theme(dark_theme)

    assert card.bgcolor == ...

invalidate_count

The host records every repaint request, which is what most hand-rolled fake apps existed to count.

assert host.invalidate_count > 0     # it asked to repaint
assert host.invalidate_count == 0    # it did not

Assert > 0 or == 0, not an exact number. Coalescing two invalidations into one is a legitimate optimisation that changes no behaviour, and == 2 would break on it — a test that fails when the implementation changes rather than when the behaviour does. Where an exact count genuinely is the contract ("this must not repaint per keystroke"), == 0 says it and a number does not.

settle() itself never invalidates, so the count is yours alone.

Subscriptions are checked for you

When the harness tears the widget down it fails the test if any subscription made during it is still open:

SubscriptionLeakError: 1 subscription(s) were created during this test and never
disposed. The observable still holds a callback into a widget that has been
unmounted, so firing it now mutates a dead tree:

  MyCard  subscribed at myapp/cards.py:64

Wrap the call in self.bind(...), which disposes it on unmount, ...

You get this without asking for it, because it is the framework's most common bug and it is silent where it is introduced. An observable that outlives your widget — one the app passed in — keeps a reference to a callback into a tree that is gone; the symptom shows up somewhere unrelated, much later.

The fix is bind(). It stores the Disposable and disposes it on unmount:

def on_mount(self) -> None:
    super().on_mount()
    self.bind(self.source.subscribe(self._on_value))     # instead of a bare subscribe

observe(obs, cb) and bind_to(obs, setter) already do this for you, so a widget built out of those cannot leak. Subscribe in on_mount, not __init__: the binding is released at unmount, so a widget that subscribed in its constructor stops working if it is ever re-mounted.

What is not reported: a widget that was never mounted (nothing outlived anything), a widget still mounted in another harness, and the observable graph's own internal edges. A subscription with no widget behind it — app code with a lifetime the harness cannot know — is counted in the message but never fails a test on its own.

To turn it down, narrowest wins:

# pyproject.toml — the whole suite
[tool.nuiitivet.testing]
leak_check = "warn"        # "error" (default), "warn", "off"
@pytest.mark.nuiitivet(leak_check="off")   # this test
...
with mount(widget, leak_check="off"):      # this harness

Reach for "off" when a subscription genuinely outlives its widget — not to silence a report you have not read, which is the one thing this check cannot survive.

What mount() will not do

It cannot click. The action verbs go through the App's own pointer dispatch, which a minimal host does not have and should not reimplement — a second input path would drift from the real one. To drive input, use AppHarness; it is not much more setup, and it is the same query surface.

The queries (get / query / get_all / tree) and Node are identical at both levels; Testing a screen documents them once.