Testing¶
Rendering is a pure function, so testing a turbodesk app is calling it and looking at what came out. There is no pilot to drive and no snapshot file to approve.
from turbodesk.testing import render, to_text
from turbodesk.events import Key, KeyPress
from turbodesk.view import Size
def test_pressing_down_moves_the_selection():
view = render(app, size=Size(80, 24), events=[KeyPress(Key.DOWN)])
assert "▸ second" in to_text(view)
render(app, *, size, events) runs the app, feeds it the events (re-rendering between each, so a handler sees the state the previous one left) and returns the final View.
Asserting on more than text¶
to_text throws the styling away. When that is the point, read the grid:
grid = view.to_grid(view.size)
assert grid[0][0][1].bold # the first cell is bold
assert grid[2][4][1].fg == ui.theme.red
Geometry:
Testing a widget on its own¶
Widgets need a UI, which you can make directly:
from turbodesk import UI
from turbodesk.theme import MOCHA
def test_a_badge_shows_its_state():
ui = UI(Size(40, 10), MOCHA)
assert "RUNNING" in to_text(status_badge(ui.theme, AppState.RUNNING))
If the widget calls ui.now or ui.every, it needs a running loop. Make the test async and your test runner will supply one.
Timers and async work¶
import asyncio
from turbodesk.runtime import UI, drain
async def test_it_refreshes():
ui = UI(Size(80, 24), MOCHA)
ui.render(app)
await asyncio.sleep(0.05) # let `ui.every` fire
assert "updated" in to_text(ui.render(app))
drain(ui, app, view, events) is the lower-level form of render's event handling, for when you need to interleave events and sleeps yourself.
Modals¶
A modal is a coroutine, so open it, press keys at it, and collect the answer:
async def scenario() -> object:
ui.render(app)
answer = {}
async def run() -> None:
answer["value"] = await dialog.confirm(ui, "Delete?", "Sure?")
ui.spawn(run())
await asyncio.sleep(0.02)
view = ui.render(app)
drain(ui, app, view, [KeyPress(Key.ENTER)])
await asyncio.sleep(0.05)
return answer["value"]
assert asyncio.run(scenario()) is True
End to end¶
For the parts that genuinely need a terminal (raw mode, the alternate screen, ui.suspend), drive a real program on a pty:
pid, fd = pty.fork()
if pid == 0:
os.execv(sys.executable, [sys.executable, "app.py"])
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
os.write(fd, b"q")
Set the window size
A freshly forked pty has no size, so the app renders 0×0 and emits nothing. It looks exactly like a broken render loop. TIOCSWINSZ plus a SIGWINCH fixes it.
And drain the buffer as you go. Leave it unread and the program blocks writing to it, then never sees the next keystroke, which looks exactly like a hung app.
Snapshots¶
A screenful of widgets cannot be hand-asserted, and a test that checks three cells of it says nothing about the other two thousand. snapshot compares the rendered text with a committed file and gives back a unified diff:
from turbodesk.testing import snapshot, to_text
def test_the_screen_is_what_it_was(render):
diff = snapshot(to_text(render(app, size=SIZE), SIZE), SNAPSHOT)
assert diff is None, f"it drew differently:\n{diff}"
TURBODESK_UPDATE_SNAPSHOTS=1 writes the file instead of comparing, which is how a deliberate change is accepted — make snapshots does that and leaves a diff for review. Nothing is written without it: a snapshot that rewrites itself on failure tests nothing.
Two things make a snapshot honest.
Pin whatever moves. A spinner draws a different frame each tick and a clock never repeats, so a snapshot of either fails on the second run for no reason. examples/gallery.py is snapshotted with runtime.datetime monkeypatched to a fixed instant.
Assert something besides the picture. A snapshot happily records a screen with half the widgets missing, or one where nothing responds to a key. Keep a test or two that names what should be on screen and one that presses a key and looks at the result. The gallery has both.
Practical advice¶
Check coverage of the file you changed. A module can sit at 12% while the suite is green, because nothing reaches it.
Mutation-check a new test once. Break the branch on purpose and confirm the test fails. It is the only way to know it can.
Test the feature where the user enters it, at least once. It is possible to build a mechanism, test the mechanism thoroughly, and never wire it up. Every test passes and the feature is unreachable.