Testing¶
PythonNative is built so that the bulk of your application logic can
be tested without a device or simulator. The reconciler talks to
native widgets exclusively through the batched mutation protocol
(see Native views); swap the backend
for an in-memory fake and a render produces a tree of plain Python
objects that pytest can introspect.
What to test¶
- Components: assert that a component renders the expected element type with the expected props for a given input.
- Hooks: drive state transitions and verify outputs after each render.
- Reducers: pure functions; test them as you would any other Python function.
- Native modules: skip platform-only paths or mock them at the boundary.
What not to test (or to test sparingly): the platform handler
implementations themselves. Those run only on the device and are
covered by the Maestro E2E suite (tests/e2e/).
A minimal fake backend¶
A test backend implements the registry protocol: apply_mutations,
resolve_view, measure_intrinsic, and command. PythonNative's own
suite keeps a full-featured one in tests/fake_backend.py; copy it
into your project or use this trimmed version:
from pythonnative.mutations import (
CreateOp, UpdateOp, InsertOp, RemoveOp, DestroyOp, SetFrameOp,
)
class FakeView:
def __init__(self, tag, type_name, props):
self.tag, self.type_name, self.props = tag, type_name, dict(props)
self.children, self.frame = [], (0, 0, 0, 0)
def find_all(self, type_name):
out = [self] if self.type_name == type_name else []
for child in self.children:
out.extend(child.find_all(type_name))
return out
class FakeBackend:
def __init__(self):
self.views = {}
def apply_mutations(self, ops):
for op in ops:
if isinstance(op, CreateOp):
self.views[op.tag] = FakeView(op.tag, op.type_name, op.props)
elif isinstance(op, UpdateOp):
self.views[op.tag].props.update(op.changed_props)
elif isinstance(op, InsertOp):
child = self.views[op.child_tag]
self.views[op.parent_tag].children.insert(op.index, child)
elif isinstance(op, RemoveOp):
self.views[op.parent_tag].children.remove(self.views[op.child_tag])
elif isinstance(op, DestroyOp):
self.views.pop(op.tag, None)
elif isinstance(op, SetFrameOp):
self.views[op.tag].frame = op.frame
def resolve_view(self, tag):
return self.views.get(tag)
def measure_intrinsic(self, tag, max_w, max_h):
return (0.0, 0.0)
def command(self, tag, name, args=None):
return None
def set_animated_property(self, tag, prop, value): ...
def start_animation(self, tag, anim_id, prop, spec): return False
def cancel_animation(self, tag, anim_id): return None
Rendering a component in a test¶
Construct the reconciler with the fake backend directly:
import pythonnative as pn
from pythonnative.reconciler import Reconciler
def render(element):
"""Mount `element` and return (root FakeView, reconciler)."""
backend = FakeBackend()
rec = Reconciler(backend)
rec._screen_re_render = lambda: None # no screen host in tests
rec.mount(element)
return backend.views[rec.root_tag()], rec
(For a longer-running test (effects, navigation), use create_screen so
you get the full lifecycle plumbing.)
Asserting on rendered output¶
Event callbacks live in the event registry, not on the view; drive
them with dispatch_event
exactly like a native listener would:
from pythonnative.events import dispatch_event
def test_counter_increments():
@pn.component
def Counter():
count, set_count = pn.use_state(0)
return pn.Column(
pn.Text(f"Count: {count}", key="t"),
pn.Button("+", on_click=lambda: set_count(count + 1), key="b"),
)
root, rec = render(Counter())
label, button = root.children
assert label.props["text"] == "Count: 0"
dispatch_event(button.tag, "on_click")
rec.flush_dirty() # state changes commit on the next flush
assert root.children[0].props["text"] == "Count: 1"
Notes:
key="t"andkey="b"aren't required for a two-child column, but using them in tests makes assertions more robust as the component evolves.- Callable props never appear in
props; the view carries a_pn_eventsfrozenset naming the wired events, anddispatch_event(tag, name, *args)invokes the current callback.
Testing hooks in isolation¶
For complex hook compositions (a custom hook that wraps several built-ins), wrap the hook in a tiny throwaway component and assert on its rendered shape:
def test_use_toggle():
def use_toggle(initial=False):
on, set_on = pn.use_state(initial)
return on, lambda: set_on(not on)
@pn.component
def Probe():
on, toggle = use_toggle()
return pn.Text("on" if on else "off", on_click=toggle, key="t")
root, rec = render(Probe())
assert root.props["text"] == "off"
dispatch_event(root.tag, "on_click")
rec.flush_dirty()
assert root.props["text"] == "on"
Testing layouts¶
The flexbox engine in pythonnative.layout is pure Python and easy
to test in isolation. For a single tree:
from pythonnative.layout import LayoutNode, calculate_layout
def test_row_distributes_flex_children():
root = LayoutNode(
style={"flex_direction": "row", "width": 300, "height": 50,
"spacing": 10},
children=[
LayoutNode(style={"flex": 1, "height": 50}),
LayoutNode(style={"flex": 2, "height": 50}),
],
)
calculate_layout(root, 400, 600)
a, b = root.children
assert a.x == 0 and a.width == (300 - 10) / 3
assert b.x == a.width + 10 and b.width == 2 * (300 - 10) / 3
To test the layout pass for a real component (with the mock registry),
use Reconciler.compute_layout_for_test
which returns the computed LayoutNode tree.
Testing native modules¶
Native modules call into platform SDKs directly, so unit-testing them with the real implementation requires a device. For most app tests it's enough to inject a fake at the boundary:
class FakeFs:
def __init__(self):
self.store = {}
def write_text(self, path, content):
self.store[path] = content
def read_text(self, path):
return self.store[path]
Pass the fake into your component (via a context, a default argument,
or a module-level injection) and assert on store.
Running the suite¶
PythonNative uses pytest plus the standard CI matrix (Ruff, Black,
MyPy). Run them all locally before pushing:
ruff check src/pythonnative
ruff format --check
black --check src/pythonnative
mypy src/pythonnative
pytest
The same commands run in CI on every push and pull request.
Next steps¶
- Wrap subtrees with Error boundaries so test failures don't crash unrelated assertions.
- See how mocks are wired underneath: Native views.