Testing Terminal Apps & TUIs
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A command-line program that prints lines to a pipe is easy to test: run it, compare stdout, stderr and the exit status.
A program that detects a terminal, switches to the alternate screen, reads single keys and redraws cells is not. Its
behaviour depends on a terminal being present, so the test has to supply one. This page is about the tests that do:
driving the real program through a pseudo-terminal (PTY), reading what it drew, and running that on Windows, macOS and
Linux. The tests that do not need a terminal (golden --json output, exit codes, --help snapshots, "never prompts
without a TTY") are on Testing CLIs and are not repeated here.
The page was checked on 2026-10-25 against the documentation of pexpect 4.9.0, pyte 0.8.x, Expect 5.45.4,
expectrl 0.9.0, rexpect 0.7.1, portable-pty 0.9.0, vt100 0.18.1, the tmux manual, asciinema, VHS and the GitHub-hosted
runner reference. The Tcl Expect script and the Python tests (pexpect 4.9.0, pyte, pytest, Python 3.9) were run against a
small stand-in for the shelf tool on macOS, and all passed. The Rust examples, the tmux script, the VHS tape, the asciinema commands and the GitHub
Actions workflow were not compiled or run while this page was written: their APIs and options were checked against the
documentation, but run each one before relying on it. The terminal model underneath (master and subsidiary, line discipline,
SIGWINCH) is on Terminals, TTYs & Pseudo-Terminals, and the Windows side on
Windows Console & Processes.
What a terminal test has to provide
A full-screen program asks the terminal questions and acts on the answers: is stdin a terminal, what is the window size, which keys arrived, what may I draw. A test harness that wants the real program to run must therefore be a terminal. The harness opens a PTY, starts the program on the subsidiary end, writes keystrokes to the manager end, and reads the bytes the program draws. The bytes contain escape sequences (cursor moves, colours, the switch to the alternate screen), so "what is on the screen" is an interpretation of the bytes, which needs a terminal emulator.
Tests at different layers see different things, and a suite normally uses several.
| Layer | Sees | Does not see |
|---|---|---|
Logic tests (state update functions, |
Every state transition, deterministically and in microseconds |
Whether the real input source produces those events; anything about the terminal |
In-process rendering tests (Ratatui |
The cells the framework would draw, for a size and a key sequence you choose |
Raw mode, the alternate screen, signal handling, the exit status, escape sequences actually written |
Real-PTY tests (this page) |
The compiled program as a user’s terminal would see it: setup and restore of the terminal, exit codes, resize, escape sequences |
Nothing is mocked, but the tests are slower and depend on timing |
tmux-driven tests |
The same, inside a real terminal multiplexer, with its own interpretation of the screen |
Whatever tmux itself changes (it is a terminal emulator with its own |
Recorded sessions (VHS, asciinema) |
The program running in someone’s terminal emulator, as a video or text capture |
Assertions: a recording is evidence for a person, unless you diff its text output |
Rendering tests per library are covered where each library is: Ratatui: Testing & Ecosystem, Python TUIs with Textual, Rich & prompt_toolkit and Go TUIs with Bubble Tea. Those pages each say, in their own words, that the in-process layer is not enough and point here for the rest.
The tools
| Tool | Language | Platforms (as documented) | Notes |
|---|---|---|---|
Expect |
Tcl |
UNIX; a Windows port is available from ActiveState |
The original. The Tcl project page says the repository is no longer maintained and points to an updated version for Tcl 8.6 and Tcl 9 under the tcltk-depot organisation. |
pexpect |
Python |
Unix pseudo-terminals for |
|
pyte |
Python |
Pure Python |
An in-memory VTxxx terminal emulator: feed it bytes, read |
expectrl |
Rust |
The README says it works on Windows; the documentation does not name the backend |
|
rexpect |
Rust |
The documentation does not list platforms; its description is "Interact with unix processes/bash" |
A loose port of pexpect: |
portable-pty |
Rust |
Part of the WezTerm project; the documentation says Windows has several PTY implementations and |
Lower level: you read and write the PTY and parse the output yourself, for example with vt100. |
vt100 |
Rust |
Pure Rust |
A parser that turns a byte stream into an in-memory screen of cells, with colours and attributes. |
tmux |
C (a program) |
Unix-like systems (the manual and wiki target OpenBSD, Linux and macOS) |
|
VHS and asciinema |
Go and Rust (programs) |
VHS: macOS, Linux, Windows; asciinema: Linux, macOS and FreeBSD packages |
Recording tools, for demonstrations and for text captures. |
Go has no standard Expect-style package, and the Go test tool for Bubble Tea, teatest, is covered on the Bubble Tea page. This page does not recommend a third-party Go PTY library because none was checked against its documentation.
Expect
Expect is the oldest of these tools and the model for the rest: spawn starts the program on a PTY, expect waits for a
pattern in its output, send writes to it. The script below drives shelf repl. It was run with the Expect installed at /usr/bin/expect on
macOS.
#!/usr/bin/expect -f
# shelf-repl.exp: drives "shelf repl" through a pseudo-terminal.
set timeout 5
spawn shelf repl
expect "shelf> "
send "list\r"
expect "Dune"
expect "shelf> "
send "quit\r"
expect eof
catch wait result
exit [lindex $result 3]
Three details matter. The keyboard sends a carriage return, so send "list\r" is what pressing Enter produces; \n
would also work with a terminal in the default mode but is not what a terminal sends. expect fails the script after
$timeout seconds (the default is 10) so a hung program cannot hang the build. And the exit status of the program is the fourth
element of the list that wait returns, which the script re-exports with exit.
Source: Expect — project page.
pexpect with a terminal emulator
pexpect is the same model in Python. pexpect.spawn(command, args, timeout=30, encoding=None, dimensions=None, env=None, ...)
starts the program on a PTY; dimensions is a (rows, columns) tuple. expect(pattern) treats a string as a regular
expression, expect_exact(pattern) as plain text (no escaping, and documented as potentially faster), and both accept pexpect.EOF and
pexpect.TIMEOUT in a list of patterns. sendline(s) appends os.linesep, send(s) writes the bytes as given,
sendcontrol('c') sends Ctrl-C, setwinsize(rows, cols) changes the size the child sees and makes the kernel send
SIGWINCH, and after close() the attributes exitstatus and signalstatus hold the result (exitstatus is None when a signal killed the child).
The shelf repl test, as plain pytest functions:
# test_repl.py
import subprocess
import sys
import pexpect
def test_repl_lists_books():
child = pexpect.spawn(sys.executable, ["shelf.py", "repl"], encoding="utf-8", timeout=5, dimensions=(24, 80))
child.expect_exact("shelf> ")
child.sendline("list")
child.expect_exact("Dune")
child.expect_exact("shelf> ")
child.sendline("quit")
child.expect(pexpect.EOF)
child.close()
assert child.exitstatus == 0
def test_repl_refuses_without_a_terminal():
result = subprocess.run([sys.executable, "shelf.py", "repl"], stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=5)
assert result.returncode == 2
The second test needs no PTY at all: it is the "no TTY" test of the CLI page, kept next to its PTY twin so that both sides of the
isatty check are covered.
Source: pexpect — API and pexpect — Overview.
A TUI test needs more than expect: it must know where things are on the screen. pyte is an in-memory VTxxx terminal
emulator: create a pyte.Screen(columns, lines), wrap it in a pyte.ByteStream, feed it the bytes read from the
PTY, and read screen.display, a list of strings, one per row. The test below starts a stand-in shelf tui (it
draws a title, three books, a highlighted selection, and quits on q), checks the first screen, moves the selection, checks that the program leaves the
alternate screen, and checks that a resize reaches the program.
# test_tui.py
import sys
import pexpect
import pyte
ROWS, COLS = 24, 80
def start():
child = pexpect.spawn(sys.executable, ["shelf.py", "tui"], timeout=5, dimensions=(ROWS, COLS))
screen = pyte.Screen(COLS, ROWS)
return child, screen, pyte.ByteStream(screen)
def read_until(child, stream, text):
"""Wait for text in the raw output and feed everything read so far to the emulator."""
child.expect_exact(text)
stream.feed(child.before + child.after)
def test_selection_moves():
child, screen, stream = start()
read_until(child, stream, b"Neuromancer")
assert screen.display[0].startswith("Bookshelf (80x24)")
assert screen.display[2].startswith("> Dune")
child.send("j")
read_until(child, stream, b"> The Dispossessed")
assert screen.display[3].startswith("> The Dispossessed")
child.send("q")
child.expect(pexpect.EOF)
child.close()
assert child.exitstatus == 0
assert b"\x1b[?1049l" in child.before # the alternate screen was left
def test_resize_is_reported():
child, screen, stream = start()
read_until(child, stream, b"Neuromancer")
child.setwinsize(30, 100) # rows, cols
child.send("j")
child.expect_exact(b"(100x30)")
child.send("q")
child.expect(pexpect.EOF)
Source: pyte — documentation and the pexpect pages above. The pyte documentation page names the
classes (pyte.Screen, pyte.ByteStream) and points to a tutorial for their methods; the feed and display usage shown here was
confirmed by running it.
The pattern generalises to any language: wait for a known piece of text, feed the bytes to an emulator, assert on rows and cells, send keys, repeat. Two points keep it reliable. Waiting is always for output, never for time, because the program and the machine run at unpredictable speeds. And an assertion on the emulated screen belongs after the wait that guarantees the frame is complete: the test above waits for the last book of the first frame and for the highlighted title of the second one, which are the last things each frame draws.
Rust: expectrl, rexpect and portable-pty
The Rust crates split into two kinds. expectrl and rexpect follow the Expect model; portable-pty gives you the PTY and nothing else.
# Cargo.toml
[dev-dependencies]
expectrl = "0.9"
portable-pty = "0.9"
vt100 = "0.18"
A test with expectrl, adapted from its README example:
// tests/repl.rs
use expectrl::{Eof, Error, Expect, Regex};
#[test]
fn repl_lists_books() -> Result<(), Error> {
let mut p = expectrl::spawn(env!("CARGO_BIN_EXE_shelf").to_string() + " repl")?;
p.expect(Regex("shelf> "))?;
p.send_line("list")?;
p.expect("Dune")?;
p.send_line("quit")?;
p.expect(Eof)?;
Ok(())
}
CARGO_BIN_EXE_<name> is the variable Cargo sets, for integration tests, to the path of the binary of the package; the
Ratatui page uses it for the same purpose. expectrl’s expect accepts a plain string, a Regex or Eof; with the async feature the
same calls are .await`ed. rexpect offers `spawn(command, Some(timeout_ms)) and the methods exp_string, exp_regex and exp_eof with the
same shape; its documentation does not state a Windows target, so a suite that must also run on Windows should use expectrl or portable-pty.
Source: expectrl on docs.rs, expectrl README and rexpect on docs.rs.
portable-pty is the right layer when you want to see the exact bytes, change the size mid-test, or read the screen yourself. The API
checked on docs.rs: native_pty_system().openpty(PtySize { rows, cols, pixel_width, pixel_height }) returns a pair of manager and subsidiary ends;
CommandBuilder::new(program) describes the child; pair.slave.spawn_command(cmd) starts it and returns a Child with try_wait,
wait and process_id; the manager end gives try_clone_reader() for the output, take_writer() for the input (callable once, and
dropping the writer sends EOF to the child) and resize(PtySize), which updates the kernel’s size and signals the child to re-read it.
The bytes can be fed to a vt100::Parser, which keeps an in-memory screen of cells with colours and attributes.
// tests/tui.rs
use std::io::{Read, Write};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
fn size(rows: u16, cols: u16) -> PtySize {
PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }
}
#[test]
fn quit_key_ends_the_program() -> Result<(), Box<dyn std::error::Error>> {
let pair = native_pty_system().openpty(size(24, 80))?;
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_shelf"));
cmd.arg("tui");
cmd.env("TERM", "xterm-256color");
let mut child = pair.slave.spawn_command(cmd)?;
drop(pair.slave); // keep only the manager end in the test
// Read on a thread: reading blocks, and a test must not.
let mut reader = pair.master.try_clone_reader()?;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = [0u8; 4096];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
});
let mut screen = vt100::Parser::new(24, 80, 0);
let deadline = Instant::now() + Duration::from_secs(5);
while !screen.screen().contents().contains("Dune") {
let remaining = deadline.saturating_duration_since(Instant::now());
screen.process(&rx.recv_timeout(remaining)?);
}
let mut writer = pair.master.take_writer()?;
writer.write_all(b"q")?;
writer.flush()?;
let status = child.wait()?;
assert!(status.success());
Ok(())
}
Source: portable-pty on docs.rs and vt100 on docs.rs. The vt100 page documents Parser::process, screen(), cell(row, col), contents_formatted() and
contents_diff(); Parser::new(rows, cols, scrollback) and contents() are used here from the crate’s API and are not described on that page, so check them against your version.
Snapshot and rendering tests per library
Each TUI library ships a way to test rendering without a terminal. They answer "did this frame contain these cells", and the pages below hold the complete examples.
| Library | In-process tool | Where it is documented here |
|---|---|---|
Ratatui |
|
Ratatui: Testing & Ecosystem. The snapshot records text, not colours. |
Textual |
|
Python TUIs with Textual, Rich & prompt_toolkit. The first run has no baseline and fails; save it with |
Bubble Tea |
Calling |
Go TUIs with Bubble Tea. teatest lives under |
Two things these tests cannot catch are the reason for the real-PTY layer: the program starting on a terminal and restoring it (raw mode, alternate screen, cursor visibility), and the translation of cells into escape sequences by the backend. Snapshot the cells in-process, run a handful of end-to-end scenarios through a PTY, and keep the PTY tests few. This page does not cover testing for ncurses, FTXUI, Spectre.Console or Terminal.Gui; their documentation was not checked for test facilities, and a PTY test as described above works for any of them because it never looks inside the program.
tmux as a test terminal
tmux is a terminal multiplexer, and it can be driven from a script: it keeps a pane, accepts keys, and prints the pane as text. That makes it a PTY harness with a screen emulator included, usable from any language and from shell. The commands, from the manual:
| Command | Behaviour |
|---|---|
|
Creates the session detached. |
|
Sends keys to a pane. Key names such as |
|
Writes the pane’s visible contents to standard output ( |
|
Sets an absolute size for a window. |
|
Destroys all sessions and the server. |
A script that waits for text, presses keys and checks the final screen:
#!/usr/bin/env bash
# tui-smoke.sh: shelf tui under tmux
set -euo pipefail
S=shelf-test
trap 'tmux kill-session -t "$S" 2>/dev/null || true' EXIT
wait_for() { # wait_for TEXT [SECONDS]
local deadline=$((SECONDS + ${2:-5}))
until tmux capture-pane -p -t "$S" | grep -qF -- "$1"; do
if (( SECONDS >= deadline )); then
echo "timed out waiting for: $1" >&2
tmux capture-pane -p -t "$S" >&2
return 1
fi
sleep 0.1
done
}
tmux new-session -d -s "$S" -x 100 -y 30 'shelf tui'
wait_for "Neuromancer"
tmux send-keys -t "$S" j
wait_for "> The Dispossessed"
tmux send-keys -t "$S" q
until ! tmux has-session -t "$S" 2>/dev/null; do sleep 0.1; done
echo "ok"
Source: tmux(1) manual page.
Pitfalls of this approach: it is Unix-only, so Windows needs another route; tmux sets its own TERM inside the session
(check what your program sees before asserting colours); the session ends when the program exits, so capture the final screen
before that, or use the remain-on-exit option that the manual describes for keeping a pane after its command exits; and a stale server from a
crashed run can make new-session fail, which is what the trap cleans up. The manual excerpt read for this page did not include the
wait-for command or the history-limit option, so they are not described here.
Recording: VHS and asciinema
A recording is not an assertion, but two uses make it part of a test strategy: keeping documentation screenshots current, and capturing the final text screen for a diff.
VHS runs a script (a tape) in a terminal and produces a GIF, MP4, WebM or PNG sequence, or a text file of the final screen. The
Ratatui and Bubble Tea pages have complete tapes; a tape that records the shelf tui and writes a .txt capture is:
# shelf-tui.tape
Output shelf-tui.gif
Output shelf-tui.txt
Require shelf
Set Shell "bash"
Set FontSize 18
Set Width 1000
Set Height 500
Type "shelf tui"
Enter
Sleep 1s
Down 1
Sleep 500ms
Type "q"
Sleep 500ms
vhs shelf-tui.tape
diff -u golden/shelf-tui.txt shelf-tui.txt
The VHS README describes the text output as a way to keep golden files for integration tests. Its output depends on timing (Sleep),
so start from a known state: fixed data, no clock, no random ids. VHS needs ttyd and ffmpeg on the PATH unless you use its
Docker image, and it is installed with Homebrew, winget, Scoop or go install. The charmbracelet/vhs-action GitHub Action regenerates recordings in CI.
Source: VHS — README.
asciinema records a terminal session into a text recording in the asciicast format and plays it back. From its quick start:
asciinema rec -c "shelf tui" shelf-tui.cast # record one command instead of a shell
asciinema play -s 2 shelf-tui.cast # play at double speed
asciinema play -i 2 shelf-tui.cast # cap idle time at 2 seconds
The quick start shows rec -c (--command), play -s (--speed), -i (idle time limit) and upload, which sends the file to asciinema.org and prints a
secret link. A recording contains everything the program printed, including anything private, so review it before uploading. Packages exist for Linux, macOS
and FreeBSD, and asciinema stream needs CLI 3.0 or newer. The documentation pages read for this page do not describe a headless mode
or an option for the window size, so use asciinema rec --help for what your version offers. A .cast file records timing too, so it is not a stable text to diff; for that
use the VHS .txt output or a PTY test with an emulator.
Source: asciinema — Quick start.
Windows, macOS and Linux
The tests above do not change shape across systems; the PTY underneath does.
| Aspect | Linux | macOS | Windows |
|---|---|---|---|
PTY mechanism |
UNIX 98 pseudo-terminals ( |
The same API, with |
ConPTY ( |
Python pexpect |
|
|
|
Rust |
expectrl, rexpect, portable-pty |
expectrl, rexpect, portable-pty |
expectrl (its README says it works on Windows) or portable-pty; rexpect’s documentation does not state Windows support |
tmux |
Available in package repositories |
Available through Homebrew |
Not a native option; use another harness |
Resize |
|
The same |
A resize event in the console input; ConPTY exposes it through |
Newlines in the output |
The PTY converts |
The same |
Sequences and text come through the pseudo-console; do not assume the same bytes as on UNIX for the same screen |
On Windows the safest way to compare screens is to feed the bytes to an emulator (vt100 or pyte) and assert on the emulated cells, not on the bytes, because the console host may redraw regions or reposition the cursor differently from a UNIX terminal. Asserting colours is also fragile for the same reason, and the legacy console and a virtual-terminal terminal may colour the same cell differently, as the Ratatui testing page notes.
In CI, run the same suite on all three systems with a matrix. The runner labels in the GitHub documentation include ubuntu-latest, windows-latest and macos-latest; the reference does not say what the
-latest labels map to in prose and notes that they are the latest stable images GitHub provides, so pin an exact label (for example ubuntu-24.04, windows-2025, macos-15)
when a new image must not change your results. The Windows virtual machines run as administrator with User Account Control disabled, so a test that passes there proves little about running as an ordinary user, and the reference says nothing about terminals on Windows runners.
# .github/workflows/tui-tests.yml
name: tui-tests
on: [push, pull_request]
jobs:
pty:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-15, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --test tui --test repl
The workflow is a skeleton: the action versions and the toolchain step are the common ones, not taken from the pages cited here. Keep tests that need a platform-specific harness (tmux) in a job
limited to ubuntu-24.04 and macos-15, with if: runner.os != 'Windows'.
Source: GitHub-hosted runners and pexpect — Overview.
Making PTY tests reliable
-
Wait for output, never for time. Every action is followed by an
expectfor text that proves the program reacted. A fixedsleepis either too short on a loaded runner or wastes time everywhere else. -
Always set a timeout. pexpect’s default is 30 seconds for the
expectfamily andNoneblocks forever; give the whole test a timeout of its own as well, and kill the child on failure (close(force=True)in pexpect sendsSIGKILLif the child ignoresSIGHUPandSIGINT). -
Fix the terminal. Choose the size (80x24 plus a narrow and a wide one) and
TERMexplicitly, and setNO_COLORwhen colour is not what the test is about. The environment you pass decides which branch of the program’s terminal detection runs. -
Control the data. Point the program at a fixture file, not the user’s real configuration, home directory or data, and give it a fixed clock if it prints dates.
-
Match line ends correctly. The PTY turns
\ninto\r\nin the output, so patterns that span lines contain\r\n. pexpect compiles patterns withre.DOTALL, treats$as unreliable because it cannot look ahead, and matches patterns ending in a plus sign or an asterisk minimally, so end patterns with a delimiter or useexpect_exact. -
Read the output. A child that writes more than the PTY buffer holds blocks until someone reads. Calling
wait()on a pexpect child with unread output can block forever, as its documentation says; read toEOFfirst. -
Keep the failure evidence. Log the raw bytes (pexpect has
logfile_read) and, on failure, print the emulated screen. A timeout with no screen dump is almost impossible to diagnose. -
Security. A PTY harness runs the real binary with the permissions of the test process. Do not run it against production data or credentials, do not echo secrets into CI logs through a
logfile, and treat recordings as data that may contain what the program printed. -
Limits. The number of UNIX 98 pairs is limited (
/proc/sys/kernel/pty/maxon Linux), which matters for a suite that opens thousands of terminals.
What the official documentation does not provide
-
None of the tools above is a standard. POSIX defines the pseudo-terminal functions, not any harness, and there is no official guidance on how a TUI should be tested.
-
The pyte documentation page names its classes and points to a tutorial but does not describe the methods used here; the usage on this page was confirmed by running it.
-
The portable-pty and expectrl pages on docs.rs list only a Linux build target; the Windows behaviour of both is stated only by the projects' own descriptions, and expectrl’s README does not name the backend.
-
rexpect states no platform support. The Go ecosystem has no counterpart that was checked.
-
The tmux manual pages read did not include
wait-fororhistory-limit, and the asciinema pages read did not document recording options beyond-c,-iand-s. -
There is no official documentation of how the Windows console host re-renders a screen compared with a UNIX terminal; the advice to assert on an emulated screen is a practical rule, not a documented guarantee.
References
-
pexpect: https://pexpect.readthedocs.io/en/stable/ and https://pexpect.readthedocs.io/en/stable/overview.html
-
expectrl: https://docs.rs/expectrl/latest/expectrl/ and https://github.com/zhiburt/expectrl
-
rexpect: https://docs.rs/rexpect/latest/rexpect/ and https://github.com/rust-cli/rexpect
-
portable-pty: https://docs.rs/portable-pty/latest/portable_pty/
-
tmux manual: https://man.openbsd.org/tmux.1
-
asciinema: https://docs.asciinema.org/manual/cli/quick-start/
-
GitHub-hosted runners: https://docs.github.com/en/actions/reference/runners/github-hosted-runners
-
Textual testing: https://textual.textualize.io/guide/testing/
-
teatest: https://pkg.go.dev/github.com/charmbracelet/x/exp/teatest/v2
-
Ratatui testing recipes: https://ratatui.rs/recipes/testing/snapshots/