Testing¶
Wybthon's reactive core, VDOM, reconciler, and event delegation are plain Python, so components can be tested in CPython with pytest, no browser required. wybthon.testing renders a view into an in-memory DOM through the real reconciler, kernel protocol, scheduler, and delegated events, then lets you query and interact with it the way a user would. Browser-specific behavior (real layout, Pyodide itself) is covered by a Playwright suite.
Two rules cover most of what's different about testing reactive code:
- Writes are staged. After
set_count(1),count()still returns the old value until the graph flushes. In the browser that happens on a microtask; in a test,fireflushes for you, andflushdoes it explicitly. - Effects run after commit.
create_effect's first run is deferred to the next flush, so assert after a flush.
Rendering components¶
render mounts a view into a fresh container, flushes it, and returns a Screen:
from wybthon import Prop, Props, button, component, create_signal, div, p
from wybthon.testing import fire, render
class CounterProps(Props):
label: Prop[str]
@component
def Counter(props: CounterProps):
count, set_count = create_signal(0)
return div(
p(t"{props.label}: {count}"),
button("+", on_click=lambda: set_count(lambda n: n + 1)),
)
def test_counter_increments():
with render(Counter(label="Clicks")) as screen:
assert screen.get_by_text("Clicks: 0")
fire.click(screen.get_by_role("button", name="+"))
assert screen.get_by_text("Clicks: 1")
Use render as a context manager to unmount when the block ends, or call screen.unmount() yourself. screen.html() returns the container's inner HTML without Wybthon's comment markers, and screen.text() returns its text with whitespace collapsed:
from wybthon import li, ul
from wybthon.testing import render
with render(ul(li("a"), li("b"))) as screen:
assert screen.html() == "<ul><li>a</li><li>b</li></ul>"
assert screen.text() == "ab"
Cleaning up between tests¶
cleanup unmounts every view that render mounted and that's still mounted. An autouse fixture in your conftest.py makes every test start from a clean slate, even when a test fails before it unmounts:
import pytest
from wybthon.testing import cleanup
@pytest.fixture(autouse=True)
def _unmount_views():
yield
cleanup()
Queries¶
Queries come in three forms, as in Testing Library:
| Form | No match | One match | Several matches |
|---|---|---|---|
get_by_* |
Raises LookupError |
Returns it | Raises LookupError |
query_by_* |
Returns None |
Returns it | Raises LookupError |
get_all_by_* |
Raises LookupError |
Returns a list | Returns a list |
| Query | Finds |
|---|---|
get_by_text(text, exact=True), query_by_text, get_all_by_text |
The deepest elements whose text matches. With exact=False the match is a case-insensitive substring; pass a compiled regular expression to search. |
get_by_role(role, name=None), query_by_role, get_all_by_role |
Elements with an explicit role or an implicit one (button, link, textbox, checkbox, heading, list, ...). name matches the accessible name: aria-label, then the text content (or placeholder for inputs). |
get_by_label_text(text), query_by_label_text |
The form control a <label> names (through for or by nesting), or the element with that aria-label. |
get_by_test_id(id), query_by_test_id, get_all_by_test_id |
Elements whose data-testid matches. data_testid="save" writes that attribute. |
Queries return TestNodes. Read .text_content, .attributes (a dict of strings), .value, .checked, and .tag, or use .classList.contains(name):
import re
from wybthon import button, div, input_, label, p
from wybthon.testing import render
with render(
div(
label("Email", html_for="email"),
input_(id="email", type="email", placeholder="you@example.com"),
p("Saved 3 items", data_testid="status"),
button("Save", class_="primary"),
)
) as screen:
assert screen.get_by_label_text("Email").attributes["type"] == "email"
assert screen.get_by_text(re.compile(r"Saved \d+ items"))
assert screen.get_by_test_id("status").text_content == "Saved 3 items"
assert screen.get_by_role("button", name="Save").classList.contains("primary")
assert screen.query_by_text("Deleted") is None
Firing events¶
fire dispatches an event through Wybthon's delegated handlers, then flushes, so the next assertion sees the settled DOM:
| Helper | What it does |
|---|---|
fire.click(node) |
Dispatches click. |
fire.input(node, value) |
Sets the control's value, then dispatches input. |
fire.change(node, value=None, checked=None) |
Sets the value or checked state, then dispatches change. |
fire.submit(form) |
Dispatches submit. |
fire.key_down(node, key) |
Dispatches keydown with key. |
fire.focus(node), fire.blur(node) |
Dispatch focus and blur. |
fire(node, "dblclick", **payload) |
Dispatches any event type. Keyword fields become event fields (shift_key=True is e.shift_key). |
from wybthon import component, create_signal, div, input_, p
from wybthon.testing import fire, render
@component
def Mirror():
text, set_text = create_signal("")
return div(
input_(aria_label="Name", value=text, on_input=lambda e: set_text(e.target.value)),
p(t"Hello, {text}"),
)
def test_input_updates_text():
with render(Mirror()) as screen:
fire.input(screen.get_by_label_text("Name"), "Ada")
assert screen.get_by_text("Hello, Ada")
Props and parents¶
Pass an accessor to drive a prop from the test, then flush after writing it. The component body runs once; only the bound text updates:
from wybthon import Prop, Props, component, create_signal, flush, p
from wybthon.testing import render
class GreetingProps(Props):
name: Prop[str]
runs: list[int] = []
@component
def Greeting(props: GreetingProps):
runs.append(1)
return p("Hello, ", props.name)
def test_prop_updates_without_rerunning_body():
name, set_name = create_signal("Ada")
with render(Greeting(name=name)) as screen:
set_name("Grace")
flush()
assert screen.text() == "Hello, Grace"
assert runs == [1]
Callbacks are plain fields, so a test can pass a recording function and assert on its calls after firing events.
Async components¶
Async memos, actions, and Loading boundaries need an event loop. Run the test body with asyncio.run (or a plugin such as pytest-asyncio), and use wait_for to wait for an observable result. It flushes between checks and raises TimeoutError if the condition never holds:
import asyncio
from wybthon import Loading, component, create_memo, p
from wybthon.testing import render, wait_for
@component
def UserCard():
async def load():
await asyncio.sleep(0)
return {"name": "Ada"}
user = create_memo(load)
return Loading(lambda: p("User: ", lambda: user()["name"]), fallback=p("Loading..."))
def test_loading_shows_fallback_then_content():
async def main() -> None:
with render(UserCard()) as screen:
assert screen.text() == "Loading..."
await wait_for(lambda: screen.query_by_text("User: Ada") is not None)
asyncio.run(main())
tick drains ready asyncio continuations and flushes, for when you want to step instead of wait. resolve awaits an async memo's next settled value: assert await resolve(user) == {"name": "Ada"}.
The reactive core, no DOM¶
Signals, memos, effects, and stores need no rendering at all. reactive_scope owns the computations created inside it and disposes them when the block ends:
from wybthon import create_effect, create_memo, create_signal, flush
from wybthon.testing import reactive_scope
def test_memo_and_effect():
with reactive_scope():
count, set_count = create_signal(0)
doubled = create_memo(lambda: count() * 2)
seen: list[int] = []
create_effect(doubled, lambda value, prev: seen.append(value))
flush() # first effect run
assert seen == [0]
set_count(1)
set_count(lambda n: n + 1) # functional updates compose
assert count() == 0 # still staged
flush()
assert count() == 2
assert seen == [0, 4] # one effect run per flush
Stores, router, and forms¶
- Store writes are staged like signal writes. Python doesn't allow assignment inside a lambda, so write draft functions with
def, pass them to the setter, and flush before asserting on reads. - Outside a browser,
navigateonly updates thecurrent_pathsignal. Flush afterward. Path matching (resolveinwybthon.router) is pure and needs no rendering. - Drive form bindings with
fire.input,fire.change, andfire.submitagainst the rendered controls.
Dev-mode diagnostics¶
Dev-mode warnings print to stderr; capture them with pytest's capsys. Warnings are deduplicated per process, so call wybthon._warnings._reset_warning_dedupe() at the start of a test that asserts on one. A write inside a memo raises WriteInScopeError when the memo is read (pytest.raises(WriteInScopeError) around the read); a write inside a single-function effect surfaces through the effect's error= handler on the next flush. Calling a component with an unknown or missing prop raises TypeError at the call.
Framework tests¶
Wybthon's own unit tests under tests/ also use a lower-level wyb fixture from tests/conftest.py. It installs stub js and pyodide modules, reloads the browser-facing modules (kernel, dom, events, reconciler, _template), and installs a kernel.PythonBackend over an in-memory document; root_element provides a fresh container. Use it when a test needs to inspect kernel commands or reconciler internals. For component behavior, prefer wybthon.testing.
Run the unit suite with:
Browser E2E suite (Playwright and Pyodide)¶
The e2e job in CI runs the browser suite under tests/e2e/. All tests carry the e2e pytest marker and exercise the real Pyodide runtime (314.0.6) in headless Chromium against a feature fixture app: an ordinary wybthon.toml project in tests/e2e/ with one route per framework feature (reactivity, holes, props, events, context, flow control, forms, stores, loading, error boundaries, lifecycle, portal, lazy loading, router, hydration, and production builds). Each per-feature test module (tests/e2e/test_*.py) drives that route and asserts through stable data-testid selectors.
Design choices that keep the suite fast and deterministic:
- Boot Pyodide once. The session-scoped fixture starts
wyb dev --dir tests/e2eand boots the app a single time; tests navigate between features with the History API router instead of reloading the page. - Isolation between tests. The
goto_featurehelper bounces through a/blankroute first, forcing the previous feature's tree to unmount. - Stable selectors. Components expose
data-testidattributes (see thetidhelper intests/e2e/app/testkit.py). - Fail fast on boot errors. The bootstrap records its status on
window.__WYB.status, and the readiness wait surfaces a boot error as a test error instead of a timeout. The page's Pyodide instance iswindow.__WYB.pyodide, and the app mounts into#app.
Run locally:
uv sync --group dev
uv run playwright install chromium
# full browser suite
uv run pytest -q -m e2e tests/e2e
# a single feature module
uv run pytest -q -m e2e tests/e2e/test_errors.py
Notes:
- The default pytest configuration excludes the browser suite from the fast unit run; pass
-m e2eto opt in. Pyodide's cold start can take a while in CI, so the tests use generous timeouts. - The fixture app under
tests/e2e/app/is Pyodide-runtime code (it uses absoluteapp.*imports that only resolve inside the built bundle), so it's excluded from mypy and never imported by the CPython unit tests.
Coverage¶
pytest-cov is in the dev group, and CI fails below 80% coverage:
Next steps¶
- Read the Contributing guide for the full local workflow.
- Browse the Performance guide for benchmarking tips.
- See the
testingAPI reference for every query and helper.