FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [View Raw Code]   [Original HTTPS Page]

pythonnative/docs/guides/testing.md at main · pythonnative/pythonnative · GitHub

Repository navigation

Latest commit

 

History

History
434 lines (337 loc) · 15.8 KB

File metadata and controls

434 lines (337 loc) · 15.8 KB

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); pythonnative.testing swaps in an in-memory backend so a render produces a tree of plain Python objects that pytest can introspect, and gives you Testing Library-style queries to do it.

What to test

  • Components: render them, find things by text or test_id, fire events, assert on what's visible.
  • Hooks: drive state transitions with [render_hook][pythonnative.testing.render_hook].
  • Navigation flows: render the whole app and press through it.
  • Reducers and helpers: pure functions; test them as you would any other Python code.

What not to test (or to test sparingly): the Swift and Kotlin component managers themselves. Those run only on the device and are covered by their XCTest and JUnit suites and by the Maestro E2E suite (tests/e2e/).

Installing pythonnative also installs a pytest plugin (registered under the pytest11 entry point), so no conftest.py is needed. It runs every test in development mode, resets the framework runtime between tests, and provides the pn_clock fixture. Disable it with -p no:pythonnative.

Development mode turns on the checks a debug build runs and a release build skips: built-in prop and style validation, hook-order checks, and checks of your components' props against their annotations. A mismatch is a [diagnostics][pythonnative.diagnostics] warning, not an exception, so assert on it when a test cares:

import pythonnative as pn
from pythonnative import diagnostics
from pythonnative.testing import render


@pn.component
def Greeting(name: str) -> pn.Node:
    return pn.Text(f"Hello, {name}")


def test_untyped_caller_is_reported() -> None:
    render(Greeting(name=42))  # type: ignore[arg-type]
    assert any("Greeting() received name=42" in w for w in diagnostics.get_warnings())

The plugin clears recorded warnings between tests.

Rendering a component

[render][pythonnative.testing.render] mounts an element and returns a [RenderResult][pythonnative.testing.RenderResult]:

import pythonnative as pn
from pythonnative.testing import render


@pn.component
def Counter() -> pn.Node:
    count, set_count = pn.use_state(0)
    return pn.Column(
        pn.Text(f"Count: {count}"),
        pn.Button("+", on_press=lambda: set_count(count + 1)),
    )


def test_counter_increments() -> None:
    result = render(Counter())
    result.press(result.get_by_role("button", name="+"))
    assert result.get_by_text("Count: 1")

press (and the general fire(target, "on_event", *args)) dispatch the event exactly as a native listener would, then settle: pending re-renders, effects, and async work run before the call returns, so the next line can assert on the new tree. Like a real tap, press refuses a disabled target (disabled=True, accessibility_state={"disabled": True}, or a disabled Pressable or Button ancestor) with an AssertionError; pass force=True to dispatch anyway. act(fn) runs a plain callable (a state setter, an imperative handle call) and settles the same way.

Queries

Queries mirror Testing Library:

Query Returns
get_by_text, get_by_test_id, get_by_label, get_by_type, get_by_role, get_by_placeholder_text, get_by_display_value exactly one view, or raises LookupError with the tree dumped in the message
query_by_* the view or None
get_all_by_* every match
find_by_* get_by_* wrapped in [wait_for][pythonnative.testing.wait_for]: polls until the view appears

Matchers are exact strings, compiled regexes, or predicates; get_by_text("Count", exact=False) matches substrings. Views inside a display: "none" subtree (inactive tabs, screens beneath the top of a stack) are skipped unless you pass hidden=True, which is how you assert that a hidden screen kept its state.

get_by_role(role, name=...) matches the accessibility_role prop, or the type's implicit role when the prop is unset, optionally narrowed by the accessible name (the accessibility_label, else the visible text or title, else the text of descendants). The implicit roles come from [IMPLICIT_ROLES][pythonnative.testing.IMPLICIT_ROLES]:

Type Implicit role
Button, Pressable, Picker, DatePicker "button"
Switch "switch"
Checkbox "checkbox"
TextInput "textbox"
Image, ImageBackground "image"
Slider "adjustable"
ProgressBar "progressbar"

Text has no implicit role; pn.Text("Title", accessibility_role="header") matches get_by_role("header") through the prop.

Each match is a [FakeView][pythonnative.testing.FakeView] with type_name, props, children, parent, frame, text, value, and placeholder. text is the visible static text (Text.text or Button.title); a TextInput's contents are exposed as value and placeholder, so get_by_text never matches a form field, while get_by_display_value and get_by_placeholder_text do. result.text() lists the visible strings in order, and result.dump() prints the tree when a test is confusing.

Scope any query to one subtree with [within][pythonnative.testing.within]:

from pythonnative.testing import render, wait_for, within


def test_form(pn_clock):
    r = render(Form())
    r.change_text(r.get_by_placeholder_text("Email"), "a@b.c")
    r.press(r.get_by_role("button", name="Save"))
    pn_clock.advance(2.0)                     # timers fire, no real sleep
    assert wait_for(lambda: r.query_by_text("Saved"))
    card = within(r.get_by_test_id("summary"))
    assert card.get_by_display_value("a@b.c")

wait_for(predicate, timeout=1.0, interval=0.01) polls predicate while draining the framework loop; a LookupError or AssertionError raised by the predicate counts as "not yet", so both get_by_* and query_by_* work inside it.

Other helpers

  • change_text(input, "value") fires on_change on a TextInput.
  • press(target, force=False) fires on_press; act(fn) runs any callable and settles.
  • back() simulates the system back action and returns whether a handler consumed it.
  • rerender(element) reconciles new root props from outside the tree.
  • settle() pumps the framework loop until async work is done.
  • unmount() tears down and runs effect cleanups; RenderResult is also a context manager.

Virtual time

Timers (asyncio.sleep, call_later, asyncio.timeout) on the framework loop can run on virtual time. The pn_clock fixture from the shipped pytest plugin installs a [FakeClock][pythonnative.testing.FakeClock]; [fake_clock][pythonnative.testing.fake_clock] is the context-manager form for code outside pytest:

from pythonnative.testing import fake_clock, render


def test_toast_disappears():
    with fake_clock() as clock:
        result = render(Toast())          # hides itself after asyncio.sleep(3)
        assert result.query_by_text("Saved")
        clock.advance(3.0)                # timers fire, no real sleep
        assert result.query_by_text("Saved") is None

The clock patches the loop's time() rather than asyncio.sleep, so the framework's own timers (transitions, animations, debounced hooks) advance together with the app's. advance() steps timer by timer, so a re-arming timer fires the right number of times.

Testing hooks in isolation

from pythonnative.testing import render_hook


def use_toggle(initial=False):
    on, set_on = pn.use_state(initial)
    return on, lambda: set_on(not on)


def test_use_toggle():
    hook = render_hook(use_toggle, True)
    assert hook.current[0] is True
    hook.act(lambda: hook.current[1]())
    assert hook.current[0] is False
    assert hook.render_count == 2

hook.rerender(*args, **kwargs) re-renders with new arguments, which is how you test hooks that react to prop changes.

Testing navigation

Navigators draw their own header when no native host is present, so a whole flow fits in one test. With the App from the Navigation guide:

def test_home_to_item_and_back() -> None:
    result = render(App())
    result.press(result.get_by_text("Open item 42"))
    assert result.get_by_text("Item 42 (details)")
    assert result.back() is True
    assert result.get_by_text("Open item 42")

To assert on navigation state, pass on_state_change to the container and record what it reports. Route names default to the screen components' names:

def test_push_records_params() -> None:
    states: list[pn.NavigationState] = []

    @pn.component
    def Harness() -> pn.Node:
        return pn.NavigationContainer(Root, on_state_change=states.append)

    result = render(Harness())
    result.press(result.get_by_text("Open item 42"))
    route = states[-1].current
    assert (route.name, route.params) == ("ItemScreen", {"id": 42, "tab": "details"})

To test how the root navigator behaves under a native host, render under a [FakeHost][pythonnative.testing.FakeHost]. Its initial_state boots the navigator "mid-stack" from a saved state, the way a pushed native screen re-entering Python does, and set_focused simulates the platform covering or revealing the screen:

from pythonnative.testing import FakeHost


def test_restores_saved_stack() -> None:
    saved = {
        "routes": [
            {"name": "HomeScreen", "params": {}, "key": "home"},
            {"name": "ItemScreen", "params": {"id": 7}, "key": "item"},
        ],
        "index": 1,
    }
    result = render(App(), host=FakeHost(initial_state=saved))
    assert result.get_by_text("Item 7 (details)")

The host also records the screen options it receives in options, with the latest title in title.

Testing layouts

Headless tests call Yoga through the pythonnative.layout host binding. The fake backend supplies deterministic intrinsic measurements; it doesn't reproduce platform fonts or control metrics. Rendered views carry their computed frame (x, y, width, height) once render runs the layout pass for the default 390x844 viewport (pass viewport=(w, h) to change it, or viewport=None to skip layout):

def test_row_distributes_flex_children():
    result = render(
        pn.Row(
            pn.View(test_id="a", style={"flex": 1, "height": 50}),
            pn.View(test_id="b", style={"flex": 2, "height": 50}),
            style={"width": 310, "gap": 10},
        )
    )
    a, b = result.get_by_test_id("a"), result.get_by_test_id("b")
    assert a.frame[2] == pytest.approx((310 - 10) / 3)
    assert b.frame[0] == pytest.approx(a.frame[2] + 10)

For the engine alone, build LayoutNode trees and call calculate_layout directly (see pythonnative.layout).

Testing native modules

Off device every native module facade resolves to a Python implementation, so pn.Clipboard, pn.SecureStore, pn.AsyncStorage, and friends work in pytest with in-memory state. To control what a module answers, register your own implementation under the module's name before the component under test runs:

from pythonnative.native_modules.registry import emit, register_python_module


class FakeLocation:
    async def get_current(self, **options):
        return {"latitude": 59.91, "longitude": 10.75}


register_python_module("Location", FakeLocation())

Methods are matched by name and receive the keyword arguments the facade passed. Module events (AppState changes, NetInfo updates, your own module's notifications) can be pushed with emit("NetInfo", "change", {"type": "none", "is_connected": False}); listeners registered through the facades or the use_* hooks fire the same way they would on device.

Use unregister_python_module(name) in a fixture teardown to restore the built-in desktop implementation.

Facades don't swallow errors: a fake that raises [NativeModuleError][pythonnative.native_modules.NativeModuleError] surfaces through the facade to the component, which is the right way to test your error handling. User cancellations and denials are ordinary return values (None from Camera.launch_camera, False from Permissions.request), so fake those by returning them.

Testing the bridge

The reconciler's on-device backend serializes commits to JSON. To assert on what native would receive (or to test a custom component's props at the wire level), install a [FakeTransport][pythonnative.bridge.fake.FakeTransport]: it decodes transactions and keeps a native-style view tree, and lets tests inject events, module results, and host lifecycle from the "native" side. tests/test_bridge.py in the repository is the reference for this style.

Testing native code

The Swift and Kotlin halves have their own suites next to the code:

cd src/pythonnative/native/ios
xcodebuild test -project Tests/NativeTests.xcodeproj -scheme NativeTests -destination 'platform=iOS Simulator,name=iPhone 15 Pro'

cd src/pythonnative/templates/android_template
./gradlew :pythonnative:testDebugUnitTest

Both drive component managers and modules with decoded transactions and promise envelopes, so a wire-format change is caught on every side. Add cases there when you change a manager or module rather than relying on the Maestro E2E suite alone.

The iOS NativeTests scheme runs the Swift tests inside a minimal UIKit app. Use that scheme for native tests: composition, selection, and focus need an application event loop and keyboard session, which the standalone Swift package test runner doesn't provide. Add new Swift test files to both the test directory and the Xcode project's PythonNativeKitTests target.

Testing async code

render and every event helper settle the framework's asyncio loop before returning, so use_effect coroutines, use_resource, use_query, and transitions complete without extra plumbing. When you trigger async work outside the helpers (a module-level task, a mutation started from a fixture), call result.settle() or [settle()][pythonnative.testing.settle], or use [pn.runtime.drain()][pythonnative.runtime.drain] / [pn.run_blocking(coro)][pythonnative.runtime.run_blocking] directly. See Testing async code.

Going lower level

A minimal fake backend

render is a thin layer over [Reconciler][pythonnative.reconciler.Reconciler] and [FakeBackend][pythonnative.testing.FakeBackend]. Tests that need to assert on the mutations themselves (how many InsertOps a reorder produced, which props an UpdateOp changed) can pass their own backend and read backend.ops, or construct the reconciler directly:

from pythonnative.reconciler import Reconciler
from pythonnative.testing import FakeBackend

backend = FakeBackend()
rec = Reconciler(backend)
rec.mount(pn.Text("hi"))
assert [type(op).__name__ for op in backend.ops] == ["CreateOp"]

Running the suite

PythonNative uses pytest plus the standard CI matrix (Ruff, Black, MyPy). Run them all locally before pushing:

./scripts/check.sh

The same commands run in CI on every push and pull request.

Two optional suites need the network and are skipped by default:

  • uv run pytest tests/packages -m network resolves every package in tests/packages/matrix.toml against the live PyPI, BeeWare, and Chaquopy indexes and asserts the outcome matches the manifest. The packages workflow runs it weekly. See PyPI packages.
  • The Maestro E2E suite under tests/e2e/ drives the example apps on an emulator and a Simulator; see tests/e2e/AGENTS.md.

Next steps


Back | FazBrowse Home | New Git URL