Skip to main content

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:

  1. Spawn real processes - Actual tmux/shell processes, not mocks
  2. Send real keystrokes - Literal bytes sent to the PTY
  3. Verify real output - Actual terminal content captured
  4. 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:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new functionality
  4. Submit a pull request

License

MIT License - see LICENSE file for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ptytest-0.1.0.tar.gz (12.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ptytest-0.1.0-py3-none-any.whl (12.1 kB view details)

Uploaded Python 3

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

Hashes for ptytest-0.1.0.tar.gz
Algorithm Hash digest
SHA256 fa55cf440bbb933431a158d8515ebde6d610aa5f4f9e3e8c5f98384ca01fbaf1
MD5 7f508ef1a27dcdeea9a3321432dc2578
BLAKE2b-256 914e4f8d55aa4f83176303ad969d4b6772574147b27861907ab1a589365a9a3a

See more details on using hashes here.

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

Hashes for ptytest-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4f739c2339b794ed7b89a5537ea5d192fefda5538a07ab1f04cbfddc4dadd9c9
MD5 df9a2fe7d93f7badccbe53a5e86e52a7
BLAKE2b-256 99d8483e29a46f7659f928211d67e39c0419795d3794237c0001d809751e8932

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page