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:
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.