Real terminal testing framework - test interactive CLI apps with actual keystrokes
Project description
ptytest
Real terminal testing framework - Test interactive CLI applications with actual keystrokes.
ptytest lets you write automated tests for interactive terminal applications (like tmux keybindings, zsh ZLE widgets, or any interactive CLI) by sending real keystrokes and verifying actual terminal output. No mocks, no fakes - just real process control via PTY.
Features
- Real Keystrokes: Send actual key sequences (Ctrl-b, Escape codes, etc.)
- Real Output: Verify actual terminal content, not mocked responses
- tmux Integration: Full control over tmux sessions, panes, and state
- ZLE Support: Test zsh line editor widgets with escape sequences
- Pytest Plugin: Auto-registered fixtures for easy test writing
- Un-gameable: Tests verify real behavior - they fail when functionality breaks
Installation
# Using pip
pip install ptytest
# Using uv
uv pip install ptytest
# From source
git clone https://github.com/brandon-fryslie/ptytest
cd ptytest
pip install -e .
Requirements
- Python 3.8+
- tmux (installed and in PATH)
- macOS or Linux
# Install tmux on macOS
brew install tmux
# Install tmux on Ubuntu/Debian
sudo apt install tmux
Quick Start
import pytest
from ptytest import TmuxSession, Keys
def test_tmux_help_keybinding(tmux_session):
"""Test that Ctrl-b h shows help pane."""
# Send Ctrl-b h
tmux_session.send_prefix_key('h')
# Verify help pane appeared
assert tmux_session.get_pane_count() == 2
# Verify content
content = tmux_session.get_pane_content()
assert "PANES" in content
def test_zsh_reverse_search(tmux_session):
"""Test Ctrl-R reverse history search."""
# Send Ctrl-R
tmux_session.send_raw(Keys.CTRL_R)
# Verify search prompt appeared
assert tmux_session.verify_text_appears("bck-i-search")
# Cancel with Ctrl-G
tmux_session.send_raw(Keys.CTRL_G)
Run tests with pytest:
pytest -v
Usage
Basic Session Control
from ptytest import TmuxSession
# Using context manager (recommended)
with TmuxSession() as session:
session.send_keys("echo hello")
assert "hello" in session.get_pane_content()
# Manual cleanup
session = TmuxSession()
try:
session.send_prefix_key('h')
assert session.get_pane_count() == 2
finally:
session.cleanup()
Sending Keys
from ptytest import TmuxSession, Keys
with TmuxSession() as session:
# Send tmux prefix + key (Ctrl-b h)
session.send_prefix_key('h')
# Send raw escape sequences (for ZLE widgets, etc.)
session.send_raw('\x1bD') # ESC D (Option+Shift+D on macOS)
session.send_raw(Keys.CTRL_R) # Ctrl-R
# Send shell commands
session.send_keys("ls -la") # Types and presses Enter
session.send_keys("hello", literal=True) # Types without Enter
Key Constants
from ptytest import Keys
# Control characters
Keys.CTRL_C # '\x03' - Interrupt
Keys.CTRL_R # '\x12' - Reverse search
Keys.CTRL_Z # '\x1a' - Suspend
# Special keys
Keys.ESCAPE # '\x1b'
Keys.ENTER # '\r'
Keys.TAB # '\t'
# Arrow keys
Keys.UP, Keys.DOWN, Keys.LEFT, Keys.RIGHT
# Function keys
Keys.F1, Keys.F2, ..., Keys.F12
# Create Meta/Alt combinations
Keys.meta('d') # Alt+D -> '\x1bd'
Keys.meta('D') # Alt+Shift+D -> '\x1bD'
# Create Ctrl combinations
Keys.ctrl('c') # Ctrl+C -> '\x03'
Verifying Output
with TmuxSession() as session:
# Get pane content
content = session.get_pane_content()
# Get specific pane
pane_ids = session.get_pane_ids()
help_content = session.get_pane_content(pane_ids[1])
# Wait for text to appear
if session.verify_text_appears("Ready", timeout=5.0):
print("App is ready!")
# Assert text appears (raises on timeout)
session.wait_for_text("Success", timeout=2.0)
# Check pane count
assert session.get_pane_count() == 2
# Check pane dimensions
height = session.get_pane_height()
width = session.get_pane_width()
Pane Management
with TmuxSession() as session:
# Split panes
session.split_window("-h") # Horizontal split (left/right)
session.split_window("-v") # Vertical split (top/bottom)
# Get pane info
pane_count = session.get_pane_count()
pane_ids = session.get_pane_ids()
# Get tmux options
help_pane_id = session.get_global_option("@help_pane_id")
Pytest Integration
ptytest automatically registers as a pytest plugin, providing fixtures:
Fixtures
# Standard fixture with user's tmux config
def test_something(tmux_session):
tmux_session.send_prefix_key('h')
# Minimal config (no ~/.tmux.conf)
def test_basic(tmux_session_minimal):
tmux_session_minimal.send_keys("echo test")
# Factory for multiple sessions
def test_multi(tmux_session_factory):
session1 = tmux_session_factory()
session2 = tmux_session_factory(width=80, height=24)
Markers
import pytest
@pytest.mark.keybinding
def test_ctrl_b_h(tmux_session):
"""Test a tmux keybinding."""
pass
@pytest.mark.zle
def test_zsh_widget(tmux_session):
"""Test a ZLE widget."""
pass
@pytest.mark.slow
def test_long_workflow(tmux_session):
"""Mark slow tests."""
pass
Run specific test categories:
pytest -m keybinding # Only keybinding tests
pytest -m zle # Only ZLE tests
pytest -m "not slow" # Skip slow tests
Examples
Testing tmux Keybindings
@pytest.mark.keybinding
def test_ctrl_b_h_toggle(tmux_session):
"""Test help pane toggle."""
# Toggle on
tmux_session.send_prefix_key('h')
assert tmux_session.get_pane_count() == 2
# Toggle off
tmux_session.send_prefix_key('h')
assert tmux_session.get_pane_count() == 1
Testing ZLE Widgets
@pytest.mark.zle
def test_zaw_widget(tmux_session):
"""Test zaw plugin activation."""
import time
time.sleep(0.5) # Wait for shell init
# Send Option+Shift+D (zaw-rad-dev)
tmux_session.send_raw('\x1bD')
time.sleep(0.5)
# Verify widget activated
content = tmux_session.get_pane_content()
assert "bad set of key/value pairs" not in content # No error
# Dismiss with Escape
tmux_session.send_raw(Keys.ESCAPE)
End-to-End Workflow
@pytest.mark.e2e
@pytest.mark.slow
def test_complete_workflow(tmux_session):
"""Test a complete user workflow."""
# Setup
tmux_session.split_window("-h")
assert tmux_session.get_pane_count() == 2
# Run command in first pane
tmux_session.send_keys("echo 'Hello from pane 1'")
# Switch to second pane
tmux_session.send_prefix_key('o')
# Run command in second pane
tmux_session.send_keys("echo 'Hello from pane 2'")
# Verify both commands executed
content = tmux_session.get_pane_content()
assert "Hello from pane 2" in content
Why "Un-gameable" Tests?
Traditional unit tests can be "gamed" with mocks that don't reflect real behavior. ptytest tests are un-gameable because they:
- Spawn real processes - Actual tmux/shell processes, not mocks
- Send real keystrokes - Literal bytes sent to the PTY
- Verify real output - Actual terminal content captured
- Test observable outcomes - Pane counts, content, state changes
If a ptytest test passes, the functionality actually works. If it fails, something is genuinely broken.
Troubleshooting
Tests fail with "tmux: command not found"
Install tmux:
brew install tmux # macOS
sudo apt install tmux # Ubuntu/Debian
Tests hang or timeout
- Check for orphaned sessions:
tmux ls - Kill old test sessions:
tmux kill-session -t ptytest-* - Increase timeout in pytest.ini or per-test
Shell not initializing properly
Increase the shell ready timeout:
session = TmuxSession(timeout=10)
Or add explicit waits:
import time
time.sleep(1.0) # Wait for shell initialization
Contributing
Contributions welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Submit a pull request
License
MIT License - see LICENSE file for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ptytest-0.1.0.tar.gz.
File metadata
- Download URL: ptytest-0.1.0.tar.gz
- Upload date:
- Size: 12.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa55cf440bbb933431a158d8515ebde6d610aa5f4f9e3e8c5f98384ca01fbaf1
|
|
| MD5 |
7f508ef1a27dcdeea9a3321432dc2578
|
|
| BLAKE2b-256 |
914e4f8d55aa4f83176303ad969d4b6772574147b27861907ab1a589365a9a3a
|
File details
Details for the file ptytest-0.1.0-py3-none-any.whl.
File metadata
- Download URL: ptytest-0.1.0-py3-none-any.whl
- Upload date:
- Size: 12.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f739c2339b794ed7b89a5537ea5d192fefda5538a07ab1f04cbfddc4dadd9c9
|
|
| MD5 |
df9a2fe7d93f7badccbe53a5e86e52a7
|
|
| BLAKE2b-256 |
99d8483e29a46f7659f928211d67e39c0419795d3794237c0001d809751e8932
|