Skip to content

Testing

Rendering is a pure function, so testing a termopy app is calling it and looking at what came out. There is no pilot to drive and no snapshot file to approve.

from termopy.testing import render, to_text
from termopy.events import Key, KeyPress
from termopy.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 termopy import UI
from termopy.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 termopy.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.

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.