Skip to main content

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.

Metadata

Release files for ptytest 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ptytest 0.1.0
File Size Uploaded Provenance
ptytest-0.1.0.tar.gz 12.4 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ptytest 0.1.0
File Interpreter ABI Platform
ptytest-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 24.5 kB

Release files / ptytest-0.1.0.tar.gz

Download URL ptytest-0.1.0.tar.gz
Size 12.4 kB
Tags Source
SHA-256 checksum
How to use checksums
fa55cf440bbb933431a158d8515ebde6d610aa5f4f9e3e8c5f98384ca01fbaf1
BLAKE2b-256 checksum
How to use checksums
914e4f8d55aa4f83176303ad969d4b6772574147b27861907ab1a589365a9a3a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.12.4

Release files / ptytest-0.1.0-py3-none-any.whl

Download URL ptytest-0.1.0-py3-none-any.whl
Size 12.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4f739c2339b794ed7b89a5537ea5d192fefda5538a07ab1f04cbfddc4dadd9c9
BLAKE2b-256 checksum
How to use checksums
99d8483e29a46f7659f928211d67e39c0419795d3794237c0001d809751e8932
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.12.4

Security for ptytest 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page