AgentSkillsCN

textual-testing

利用内置测试框架,对 Textual TUI 应用进行全面测试。支持无头模式下的 App.run_test() 测试,完整覆盖 Pilot API(鼠标、键盘、定时、动画),以及组件查询与工作线程管理。适用于为 TUI 组件编写测试用例、测试用户交互(点击、按键、悬停)、验证组件状态、测试事件处理,或执行集成测试。这是实现 Textual 功能性测试的核心技能。

SKILL.md
--- frontmatter
name: textual-testing
description: |
  Tests Textual TUI applications using the built-in testing framework. Covers headless mode testing with App.run_test(), complete Pilot API (mouse, keyboard, timing, animations), widget querying, and worker management. Use when: writing tests for TUI widgets, testing user interactions (clicks, keypresses, hover), verifying widget state, testing event handling, or running integration tests. Primary skill for functional Textual testing.

Textual Testing

Functional testing for Textual applications using App.run_test() and the Pilot class.

Quick Start

python
async def test_my_app():
    """Test a Textual application."""
    app = MyApp()
    async with app.run_test() as pilot:
        # Interact with app
        await pilot.press("enter")
        await pilot.pause()

        # Assert on state
        widget = pilot.app.query_one("#status")
        assert widget.renderable == "Done"

pytest Configuration

toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"  # No @pytest.mark.asyncio needed
testpaths = ["tests"]

App.run_test() Method

Run app in headless mode (no terminal output, all other behavior identical):

python
async with app.run_test(size=(80, 24)) as pilot:
    # size: terminal dimensions (width, height), default (80, 24)
    ...

Complete Pilot API

Properties

python
pilot.app  # Access the App instance being tested

Mouse Operations

python
# Click widget by selector, type, or instance
await pilot.click("#button")          # CSS selector
await pilot.click(Button)             # Widget type
await pilot.click(my_widget)          # Widget instance
await pilot.click(offset=(40, 12))    # Absolute coordinates

# Click with modifiers
await pilot.click("#item", shift=True)
await pilot.click("#item", control=True)
await pilot.click("#item", meta=True)

# Multiple clicks
await pilot.click("#item", times=2)   # Double-click
await pilot.click("#item", times=3)   # Triple-click
await pilot.double_click("#item")     # Alias
await pilot.triple_click("#item")     # Alias

# Click with offset from selector
await pilot.click("#widget", offset=(10, 5))

# Hover (for testing hover states, tooltips)
await pilot.hover("#menu-item")

# Raw mouse events (for drag-and-drop)
await pilot.mouse_down("#draggable")
await pilot.hover("#drop-target")
await pilot.mouse_up("#drop-target")

Keyboard Operations

python
# Press single key
await pilot.press("enter")

# Press multiple keys in sequence
await pilot.press("h", "e", "l", "l", "o")

# Type string (unpack into characters)
await pilot.press(*"hello world")

# Special keys
await pilot.press("tab", "enter", "escape", "backspace", "delete")
await pilot.press("up", "down", "left", "right")
await pilot.press("home", "end", "pageup", "pagedown")
await pilot.press("f1", "f2", "f12")

# Modifier combinations
await pilot.press("ctrl+c", "ctrl+s", "ctrl+shift+p")
await pilot.press("shift+tab", "alt+f4", "meta+s")

Timing Control

python
# Wait for message queue to drain
await pilot.pause()

# Wait for messages + additional delay
await pilot.pause(0.5)  # 0.5 seconds extra

Animation Handling

python
# Wait for current animations to complete
await pilot.wait_for_animation()

# Wait for all current AND scheduled animations
await pilot.wait_for_scheduled_animations()

App Control

python
# Exit app with return value
await pilot.exit(result={"status": "success"})

# Resize terminal during test
await pilot.resize_terminal(120, 40)
await pilot.pause()  # Let resize events propagate

Worker Management

python
# Wait for all background workers to complete
await pilot.app.workers.wait_for_complete()

Widget Querying

python
# Query single widget (raises if not found or multiple matches)
button = pilot.app.query_one("#submit")
button = pilot.app.query_one(Button)
button = pilot.app.query_one("#submit", Button)  # With type validation

# Query multiple widgets
buttons = pilot.app.query(Button)
buttons = pilot.app.query(".action-button")

# Query methods
first = pilot.app.query(Button).first()
last = pilot.app.query(Button).last()

# Iterate
for button in pilot.app.query(".action-button"):
    assert not button.disabled

Common Test Patterns

Test Button Click

python
async def test_button_click():
    class MyApp(App):
        clicked = False

        def compose(self):
            yield Button("Click", id="btn")

        def on_button_pressed(self):
            self.clicked = True

    async with MyApp().run_test() as pilot:
        await pilot.click("#btn")
        await pilot.pause()
        assert pilot.app.clicked is True

Test Text Input

python
async def test_text_input():
    class MyApp(App):
        def compose(self):
            yield Input(id="input")

    async with MyApp().run_test() as pilot:
        await pilot.click("#input")
        await pilot.press(*"hello world")
        await pilot.pause()

        input_widget = pilot.app.query_one("#input", Input)
        assert input_widget.value == "hello world"

Test Keyboard Binding

python
async def test_keyboard_binding():
    class MyApp(App):
        BINDINGS = [("ctrl+s", "save", "Save")]
        saved = False

        def action_save(self):
            self.saved = True

    async with MyApp().run_test() as pilot:
        await pilot.press("ctrl+s")
        await pilot.pause()
        assert pilot.app.saved is True

Test Background Worker

python
async def test_background_worker():
    class MyApp(App):
        data = None

        @work
        async def fetch_data(self):
            await asyncio.sleep(0.1)
            self.data = {"loaded": True}

    async with MyApp().run_test() as pilot:
        pilot.app.fetch_data()
        await pilot.app.workers.wait_for_complete()
        assert pilot.app.data == {"loaded": True}

Test Different Terminal Sizes

python
async def test_responsive_layout():
    app = MyApp()

    # Test small terminal
    async with app.run_test(size=(40, 20)) as pilot:
        sidebar = pilot.app.query_one("#sidebar")
        assert not sidebar.is_visible  # Hidden on small screens

    # Test large terminal
    async with app.run_test(size=(120, 40)) as pilot:
        sidebar = pilot.app.query_one("#sidebar")
        assert sidebar.is_visible  # Visible on large screens

Test with Terminal Resize

python
async def test_resize_handling():
    async with MyApp().run_test(size=(80, 24)) as pilot:
        assert pilot.app.size == (80, 24)

        await pilot.resize_terminal(120, 40)
        await pilot.pause()

        assert pilot.app.size == (120, 40)

Common Pitfalls

PitfallSolution
Assertion fails before updateAdd await pilot.pause() after interactions
Worker result not availableUse await pilot.app.workers.wait_for_complete()
Animation state variesUse await pilot.wait_for_animation()
Missing async defAll test functions must be async def
Missing awaitAll pilot methods are async and need await

See Also