Skip to content

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:

assert view.size == Size(80, 24)
assert view.find("cursor") == Region(4, 2, 1, 1)

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.