Testing Your App
Wijjit ships a headless test runner so you can drive a real application the way a user would – pressing keys, typing, clicking, scrolling, advancing animations – and then assert on what the app renders, all without a TTY. The same runner powers quick command-line inspection of a demo and a set of pytest fixtures for your own suite.
This page covers testing your Wijjit app. For contributing to the framework itself, see Testing.
Note
The runner feeds scripted input through the same event-loop dispatch path used in production. Focus traversal, modal blocking, overlay compositing, and async handler dispatch all run exactly as they do for a real user, so a test exercises the real interaction model rather than a mock of it.
The harness
wijjit.testing.WijjitHarness wraps a
Wijjit app, renders it at a fixed terminal size, and
exposes synchronous methods to drive and inspect it. Use it as a context
manager so setup and teardown are automatic:
from wijjit import Wijjit, render_template_string
from wijjit.testing import WijjitHarness
app = Wijjit(initial_state={"name": "", "status": "ready"})
@app.view("main", default=True)
def main_view():
return render_template_string(
"""
{% frame title="Login" border="single" width=40 %}
{% vstack spacing=1 padding=1 %}
{{ state.status }}
{% textinput id="name" placeholder="name" width=20 %}{% endtextinput %}
{% button action="greet" %}Greet{% endbutton %}
{% endvstack %}
{% endframe %}
"""
)
@app.on_action("greet")
def greet(event=None):
app.state["status"] = "Hello " + (app.state["name"] or "world")
def test_greeting():
with WijjitHarness(app, size=(40, 12)) as h:
h.press("tab") # focus the text input
h.type("Ada")
h.press("tab") # focus the Greet button
h.press("enter") # activate the "greet" action
h.assert_text("Hello Ada")
Every input method returns the harness, so calls chain:
h.press("tab").type("Ada").press("enter").
Note
The leading press("tab") is needed because this template does not mark
any field autofocus=True, so the app starts with nothing focused. If you
add autofocus to the input, drop that Tab - otherwise it moves focus
past the input and the test types into the button.
Counting Tab presses from an assumed starting point is brittle in general:
the app object is usually shared across tests, and focus survives between
them. Prefer app.focus_element_by_id("name") inside the harness block
when a test needs a specific element focused.
Driving input
All input methods are synchronous; the harness pumps the async event loop internally on a private loop, so your test code stays flat.
press(key)– press one key. Accepts a friendly name ("enter","tab","shift+tab","up","esc","backspace", …), a control chord ("ctrl+q"), or a single character.type(text)– type a string one character at a time into the focused element.key(key_obj)– enqueue a rawwijjit.terminal.input.Keywhen you need full control over key type or escape sequence.click(x, y, button="left", count=1)– click a screen cell (0-based).count=2produces a double-click;buttonis"left","middle", or"right"(right-click opens context menus).scroll(x, y, direction="down", amount=1)– send wheel events at a cell.tick(frames=1)– advance animation frames (spinners, progress) and re-render, without waiting on a wall clock.
with WijjitHarness(app, size=(80, 24)) as h:
h.press("tab")
h.type("admin")
h.click(10, 6) # routed through the real mouse router
h.scroll(40, 12, "down", amount=3)
h.tick(frames=5) # advance a spinner five frames
Waiting for asynchronous updates
When a view resolves asynchronously, a background task updates state, a
notification expires, or an animation needs to reach a target frame, the UI
settles over several frames rather than immediately. Use wait_for /
wait_for_text instead of guessing a frame count:
with WijjitHarness(app, size=(80, 24)) as h:
h.press("enter") # kicks off async work
h.wait_for(lambda: h.state["status"] == "done") # pump until settled
h.wait_for_text("Loaded") # or wait for output
h.assert_text("Loaded")
wait_for(predicate, *, frames=100, tick=False) pumps one frame at a time –
rendering and yielding to any pending asyncio tasks – and re-checks
predicate after each, returning as soon as it is true. wait_for_text is
sugar for waiting on rendered output.
The harness has no wall clock – rendering is deterministic and
frame-driven – so the timeout is a frame budget, not seconds. This keeps
waits reproducible across machines and CI. If the condition never holds within
frames frames, both methods raise TimeoutError with the final screen
included for context. Pass tick=True to also advance one animation frame per
iteration when the condition depends on spinner/animation progress.
Inspecting the result
screen()– the rendered screen as plain text (no ANSI).screen_ansi()– the screen as an ANSI string, with styling.lines()– the screen as a list of plain-text rows.find_text(text)– whethertextappears anywhere on screen.tree()– the last renderedVNodetree (the “DOM”), orNonefor a plain-text template.state– the app’s reactiveState.focused– the currently focused element, if any. Non-Noneon the very first frame when some element declaresautofocus; otherwiseNoneuntil the first Tab.running– whether the event loop is still running (Falseafter quit).
Assertions
The harness bundles assertions that fail with useful context (the offending
screen or tree) rather than a bare False:
assert_text(text)– asserttextis visible; the failure message includes the full screen.assert_no_errors()– assert the app reported no render, action, or background-task errors while the harness drove it. It catches errors the app logged and kept running through, so a template that raises mid-render is caught here even if the screen still looks plausible.assert_tree_contains(type=..., key=..., props=...)– assert a matching VNode exists in the tree (match by type, reconciliation key/id, and/or a subset of props).assert_screen(snapshot)– compare the plain-text screen against a syrupysnapshotfixture.
def test_renders_cleanly():
with WijjitHarness(app, size=(80, 24)) as h:
h.press("tab")
h.assert_no_errors()
h.assert_tree_contains(type="Button", props={"action": "greet"})
Pytest fixtures
Installing Wijjit registers a pytest11 plugin, so two factory fixtures are
available in any suite with no conftest.py wiring (opt out per run with
-p no:wijjit):
wijjit_make_app– build aWijjitfrom a template string (viaapp_from_template()) or pass an existing app through unchanged.wijjit_harness– build and start aWijjitHarnessfrom an app or a template string; each harness is closed automatically at teardown.
Pass a template string to build the app on the fly, or a pre-built app when
handlers need to close over it (action/key handlers receive an event, not
the app, so they reach state through the app they were defined against):
from wijjit.testing import app_from_template
def test_counter(wijjit_harness):
app = app_from_template(
"""
{% frame title="Counter" %}
Count: {{ state.count }}
{% button action="inc" %}+1{% endbutton %}
{% endframe %}
""",
state={"count": 0},
)
@app.on_action("inc")
def inc(event=None):
app.state["count"] += 1
h = wijjit_harness(app, size=(40, 8)) # started; closed at teardown
h.press("tab").press("enter")
h.assert_text("Count: 1")
For a view with no handlers – just rendering state – you can hand
wijjit_harness the template string directly:
wijjit_harness("{% frame %}{{ state.msg }}{% endframe %}", state={"msg": "hi"}).
The plugin also defines the wijjit_app and wijjit_snapshot markers.
Fixture names are wijjit_-prefixed so they never collide with fixtures in
your own suite.
Building an app from a template
wijjit.testing.app_from_template() (also exported from the top-level
wijjit package) builds a ready-to-drive app from a bare template string –
no example .py file needed. Pass state, actions, on_key, and
views to wire it up:
from wijjit.testing import app_from_template, WijjitHarness
app = app_from_template(
"{% frame title='T' %}{{ state.msg }}{% endframe %}",
state={"msg": "hi"},
)
with WijjitHarness(app) as h:
h.assert_text("hi")
Driving apps from the command line
For quick, interactive iteration on a visual bug – or to hand an LLM a way to
“see” a screen – render any .py app headlessly and print the result:
# via the wijjit CLI
wijjit render examples/advanced/login_form.py \
--size 100x30 --keys "type:admin,tab,type:secret,enter" --ansi
# equivalent module entry point
python -m wijjit.testing examples/widgets/spinner_demo.py --tick 5
The --keys script is a comma-separated list of steps, each one of: a named
key (tab, enter, ctrl+q, …) or single character; type:TEXT to
type a literal string; click:X,Y to left-click a cell; or tick /
tick:N to advance animation frames. Add --ansi to print the styled
screen instead of plain text, and --size WIDTHxHEIGHT to set the render
size.
To launch an app interactively in your own terminal instead, use
wijjit run path/to/app.py. To lint or inspect a template without driving it,
see wijjit validate and wijjit tree.