Key points
- Run the TUI in a pseudoterminal at a fixed size, because it needs a terminal to draw its screen.
- Wait for the expected text on the screen with a deadline instead of sleeping for a fixed time.
- Compare the rendered screen with a reviewed snapshot, and check that each exit path restores the terminal.
How do you test a terminal UI (TUI)?
To test a terminal UI (TUI), run the built program in a pseudoterminal at a fixed size and send it keystrokes. Wait until the screen shows the expected text, then compare the whole screen with a saved snapshot. Quit the program at the end and check that the terminal is back to its normal state.
A TUI, short for terminal user interface, draws an interactive screen inside a terminal, e.g. a list that moves with the arrow keys. Its output mixes text with escape sequences that move the cursor and set colors. Most TUIs also need a TTY, a terminal device the program can detect. They switch it to raw mode to read single key presses and ask it for the window size. Both calls fail on a pipe.
A pseudoterminal (PTY) gives the program that device without a window. The Linux manual describes it as a pair of virtual character devices. The program's end behaves as a terminal, and the test holds the other end, where it writes keys and reads output. A terminal emulator in the test rebuilds the screen from that output.
The result is an end-to-end test of a command-line application through its screen.
What do you need before you start?
Gather these before writing the first TUI test:
- The built program. Test the binary that users run, e.g.
acme browse. - A driver. It runs the program in a pseudoterminal, sends keys, and reads the screen.
- A fixed terminal. Set the size,
TERM, and locale in the test. - A snapshot folder. Keep approved screens there as reviewed text files.
A test can drive and read a TUI in four ways:
| Approach | Examples | What the test reads |
|---|---|---|
| Test backend in the process | Ratatui's TestBackend, teatest for Bubble Tea, Textual's run_test | The app's output or state, with no terminal |
| Pseudoterminal and emulator library | pexpect with pyte, node-pty with @xterm/headless | The screen rebuilt from real output |
| Terminal multiplexer | tmux send-keys and capture-pane | The screen that tmux keeps for the pane |
| TUI test tool | Microsoft's tui-test | Screen text and styles |
A backend in the process is the fastest, but it skips terminal setup, key decoding, and the alternate screen. Keep a few tests in a pseudoterminal.
How do you test a terminal UI step by step?
Here is an illustrative example. Acme Co. sells furniture online, and its command-line tool, acme, exports orders. A developer at Acme asks a coding agent to "Add a browse command that lets a user scroll through orders and open one." The agent's tests send keys to the app's state and pass. The developer writes a test harness script that drives the built program through tmux, a terminal multiplexer.
1. Start a terminal with a fixed size
tmux new-session -d -s acme -x 80 -y 24 \
-e 'PS1=$ ' -e LC_ALL=C.UTF-8 'bash --norc'
The shell gets a pseudoterminal of 80 columns by 24 rows, the default in tmux. The prompt and locale are fixed, and tmux sets TERM itself.
2. Start the program and wait for its first screen
wait_for() { # poll the screen for up to 5 seconds
for _ in $(seq 50); do
tmux capture-pane -p -t acme | grep -qF -- "$1" && return 0
sleep 0.1
done
echo "timed out waiting for: $1" >&2
return 1
}
tmux send-keys -t acme 'acme browse; echo "exit=$?"' Enter
wait_for 'Orders'
capture-pane -p prints the screen as plain text, one line per row, with no colors unless -e is added.
3. Press keys and save the screen as a snapshot
tmux send-keys -t acme Down Enter
wait_for 'Delivery address'
tmux capture-pane -p -t acme > tests/tui/order.txt
tmux sends Down and Enter as the bytes that match the program's key mode. The developer reviews the top rows of this first snapshot:
Order A-1042
Items 2
Total $240.00
Delivery address 12 Elm Street
The file is a golden file with one line per row, so a layout change shows up as a changed line.
4. Compare later runs with the snapshot
tmux capture-pane -p -t acme | diff -u tests/tui/order.txt -
When the screen differs, diff prints the changed rows and returns exit code 1, which fails the test. This screen matches.
5. Quit and check that the terminal is restored
$ tmux send-keys -t acme q
$ wait_for 'exit=0'
$ tmux display-message -p -t acme '#{alternate_on} #{cursor_flag}'
1 0
The two flags should read 0 1, the main screen and a visible cursor. The program left the alternate screen on and the cursor hidden, so a user faces a stale screen. Its exit code is 0, so an exit code check alone passes.
6. Fix the bug and keep the test
The agent's handler for q ended the process at once and skipped the TUI library's cleanup. Given the script's output, the agent changes the handler to let the library restore the terminal. The rerun prints 0 1.
The checks for shell scripts, e.g. ShellCheck, apply to the harness too. This example is simplified. A real TUI would need screens at a second size, e.g. 120 columns by 40 rows.
What is the alternate screen buffer?
The alternate screen buffer is a second screen that a terminal keeps next to its main screen, usually with no scrollback. Most TUIs switch to it on start and back on exit, so the earlier shell output reappears unchanged. Programs usually send \e[?1049h to enter it and \e[?1049l to leave, which begin the smcup and rmcup entries in xterm's terminfo.
The alternate screen affects a TUI test in three ways:
- Captures depend on timing. A capture during the run reads the alternate screen, and one after exit reads the main screen.
- Messages there disappear. A TUI's stdout and stderr share one terminal, so an error printed on the alternate screen vanishes on exit. A crash handler should restore the terminal before it prints the error.
- Exit has to undo it. A program that skips the leave sequence keeps the user on the alternate screen and can leave the cursor hidden, as in step 5.
What changes when a coding agent writes the code?
A coding agent's shell tool usually reads output through a pipe, so the agent cannot use its TUI the way a person does. The agent then tests the code it can call directly, e.g. the app's update function with a key event. Those tests never open a terminal, so they cannot fail on a missing restore or a wrong key byte.
An agent that reads the screen through tmux gets text without styles. Many TUIs mark the selected row only with color or reverse video, so a plain capture can be the same whichever row is selected.
A practical adjustment is to give the agent the tmux script from the steps, so it runs the built program after each change. Add -e to captures where color carries meaning, and have a person review each snapshot update.
What are common mistakes?
These mistakes make TUI tests pass on broken screens or fail on working ones:
- Fixed sleeps. A
sleep 1before a capture can fail on a slow or busy runner and make a flaky test. Wait for text with a deadline. - Reading the raw output. Output through a pipe is not the screen, and raw bytes from a pseudoterminal hold redraws. Compare the emulated screen.
- Hard-coded key bytes. Enter is a carriage return, and Down is
\e[Bor\eOBdepending on the program's cursor mode. A program can ignore the wrong form, so use a driver's named keys. - An unpinned terminal. A continuous integration (CI) job usually has no terminal, may leave
TERMunset, and may use a locale without UTF-8. - Unmasked changing values. Clocks and spinners can differ between runs. Mask them before the comparison.
- Testing one exit path. A TUI can restore the terminal after q but not after Ctrl+C or a crash, so check each exit path.
How do you check that it worked?
A TUI test works when it fails on a broken build and passes on a fixed one. Check it in three ways:
- Break it on purpose. Run the test against the build before the fix, and confirm that it fails.
- Run it many times. Run it 20 times in a row on a laptop, then in CI. A failure on unchanged code usually points to a missing wait or an unmasked value.
- Use the TUI by hand. A person uses the program in a real terminal, resizes the window, and presses unexpected keys. That exploratory testing finds screens that no snapshot covers.
FAQs
Can you snapshot a terminal screen?
A terminal screen can be saved as a snapshot after a terminal emulator applies the program's escape sequences. The snapshot is usually plain text with one line per row, which drops colors and styles unless the tool records them too.
How do you send special keys to a TUI in a test?
Special keys reach a TUI as the bytes a terminal would send, e.g. an escape sequence for an arrow key. A driver with named keys, e.g. tmux, sends the bytes that match the program's key mode.
Why do TUI tests fail in CI?
TUI tests often fail in CI because the job has no terminal and a different environment. The terminal type can be unset, the locale can lack UTF-8, and a slower machine breaks fixed sleeps. A test that creates its own pseudoterminal and pins these settings avoids most of them.
What terminal size should a TUI test use?
A TUI test should use a fixed terminal size, often 80 columns by 24 rows, the default in tmux. Add a second size for layouts that change with width, with one snapshot per size.