返回 Skill 列表
extension
分类: 开发与工程无需 API Key

textual-test-patterns

测试特定Textual TUI场景的食谱。为常见的测试需求提供“我如何测试X?”模式。使用场景:测试键盘快捷键、屏幕转换、焦点管理、响应式属性、自定义消息/事件、CSS样式效果、数据表、滚动、输入验证、错误/加载状态或后台工作。每个模式都展示了带有适当异步处理和断言的完整测试。

person作者: jakexiaohubgithub

Textual Test Patterns

Testing recipes for specific Textual scenarios. Each pattern shows complete, working test code.

Pattern Index

| Scenario | Pattern | |----------|---------| | Keyboard shortcuts | Test Keyboard Shortcuts | | Screen transitions | Test Screen Transitions | | Focus management | Test Focus Management | | Reactive attributes | Test Reactive Attributes | | Custom messages | Test Custom Messages | | CSS styling | Test CSS Styling | | Data tables | Test Data Tables | | Scrolling | Test Scrolling | | Input validation | Test Input Validation | | Error states | Test Error States | | Loading states | Test Loading States | | Background workers | Test Background Workers |

Test Keyboard Shortcuts

async def test_keyboard_shortcuts():
    """Test keyboard binding triggers action."""
    class MyApp(App):
        BINDINGS = [("ctrl+s", "save", "Save")]
        saved = False

        def action_save(self) -> None:
            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 Screen Transitions

from textual.screen import Screen

class MainScreen(Screen):
    pass

class SettingsScreen(Screen):
    pass

async def test_screen_navigation():
    """Test navigating between screens."""
    class MyApp(App):
        SCREENS = {"settings": SettingsScreen}

        def compose(self):
            yield Static("Main")

        def key_s(self):
            self.push_screen("settings")

    async with MyApp().run_test() as pilot:
        # Start on default screen
        assert not isinstance(pilot.app.screen, SettingsScreen)

        # Navigate to settings
        await pilot.press("s")
        await pilot.pause()
        assert isinstance(pilot.app.screen, SettingsScreen)

        # Navigate back
        await pilot.press("escape")
        await pilot.pause()
        assert not isinstance(pilot.app.screen, SettingsScreen)

Test Focus Management

from textual.widgets import Input

async def test_focus_order():
    """Test Tab moves focus through widgets."""
    class MyApp(App):
        def compose(self):
            yield Input(id="name")
            yield Input(id="email")
            yield Input(id="phone")

    async with MyApp().run_test() as pilot:
        name = pilot.app.query_one("#name", Input)
        email = pilot.app.query_one("#email", Input)

        # First input focused by default
        assert name.has_focus

        # Tab to next
        await pilot.press("tab")
        await pilot.pause()
        assert email.has_focus
        assert not name.has_focus

        # Shift+Tab back
        await pilot.press("shift+tab")
        await pilot.pause()
        assert name.has_focus

Test Reactive Attributes

from textual.reactive import reactive
from textual.widgets import Static

class StatusWidget(Static):
    status: reactive[str] = reactive("idle")

    def watch_status(self, new_status: str) -> None:
        self.add_class(f"status-{new_status}")
        self.remove_class(f"status-{self._previous_status}")
        self._previous_status = new_status

    def __init__(self):
        super().__init__()
        self._previous_status = "idle"

async def test_reactive_attribute():
    """Test reactive attribute triggers watcher."""
    class TestApp(App):
        def compose(self):
            yield StatusWidget(id="status")

    async with TestApp().run_test() as pilot:
        widget = pilot.app.query_one("#status", StatusWidget)

        assert widget.status == "idle"
        assert widget.has_class("status-idle")

        widget.status = "loading"
        await pilot.pause()

        assert widget.has_class("status-loading")
        assert not widget.has_class("status-idle")

Test Custom Messages

from textual.message import Message

class ItemSelected(Message):
    def __init__(self, item_id: str) -> None:
        super().__init__()
        self.item_id = item_id

async def test_custom_message():
    """Test custom message is received by handler."""
    class MyApp(App):
        selected_items: list[str] = []

        def compose(self):
            yield Static("Item", id="item")

        def on_item_selected(self, message: ItemSelected) -> None:
            self.selected_items.append(message.item_id)

    async with MyApp().run_test() as pilot:
        # Post message
        pilot.app.post_message(ItemSelected("item-1"))
        await pilot.pause()

        assert pilot.app.selected_items == ["item-1"]

        # Post another
        pilot.app.post_message(ItemSelected("item-2"))
        await pilot.pause()

        assert pilot.app.selected_items == ["item-1", "item-2"]

Test CSS Styling

from textual.color import Color

async def test_css_class_application():
    """Test CSS class changes styling."""
    class MyApp(App):
        CSS = """
        .error { background: red; }
        .success { background: green; }
        """

        def compose(self):
            yield Static("Status", id="status")

    async with MyApp().run_test() as pilot:
        status = pilot.app.query_one("#status")

        # Add error class
        status.add_class("error")
        await pilot.pause()
        assert status.has_class("error")

        # Switch to success
        status.remove_class("error")
        status.add_class("success")
        await pilot.pause()
        assert status.has_class("success")
        assert not status.has_class("error")

Test Data Tables

from textual.widgets import DataTable

async def test_data_table():
    """Test data table row selection."""
    class MyApp(App):
        def compose(self):
            yield DataTable(id="table")

        def on_mount(self):
            table = self.query_one("#table", DataTable)
            table.add_columns("Name", "Value")
            table.add_rows([
                ("Alice", "100"),
                ("Bob", "200"),
                ("Carol", "300"),
            ])

    async with MyApp().run_test() as pilot:
        table = pilot.app.query_one("#table", DataTable)

        assert table.row_count == 3

        # Navigate and select
        await pilot.click(DataTable)
        await pilot.press("down", "down")
        await pilot.pause()

        assert table.cursor_row == 2

Test Scrolling

from textual.containers import ScrollableContainer

async def test_scrolling():
    """Test scroll position changes."""
    class MyApp(App):
        def compose(self):
            with ScrollableContainer(id="container"):
                for i in range(100):
                    yield Static(f"Line {i}")

    async with MyApp().run_test(size=(80, 10)) as pilot:
        container = pilot.app.query_one("#container", ScrollableContainer)

        # Start at top
        assert container.scroll_y == 0

        # Scroll down
        await pilot.press("pagedown")
        await pilot.pause()
        assert container.scroll_y > 0

        # Scroll to end
        await pilot.press("end")
        await pilot.pause()
        assert container.scroll_y == container.max_scroll_y

Test Input Validation

from textual.widgets import Input
from textual.validation import Validator, ValidationResult

class EmailValidator(Validator):
    def validate(self, value: str) -> ValidationResult:
        if "@" in value and "." in value.split("@")[-1]:
            return self.success()
        return self.failure("Invalid email")

async def test_input_validation():
    """Test input field validates correctly."""
    class MyApp(App):
        def compose(self):
            yield Input(id="email", validators=[EmailValidator()])

    async with MyApp().run_test() as pilot:
        input_widget = pilot.app.query_one("#email", Input)

        # Invalid input
        await pilot.click(Input)
        await pilot.press(*"invalid")
        await pilot.pause()
        assert not input_widget.is_valid

        # Clear and enter valid
        input_widget.value = ""
        await pilot.press(*"user@example.com")
        await pilot.pause()
        assert input_widget.is_valid

Test Error States

async def test_error_display():
    """Test error message shows and dismisses."""
    class MyApp(App):
        def compose(self):
            yield Static("", id="error", classes="hidden")

        def show_error(self, msg: str):
            error = self.query_one("#error")
            error.update(msg)
            error.remove_class("hidden")

        def dismiss_error(self):
            self.query_one("#error").add_class("hidden")

    async with MyApp().run_test() as pilot:
        error = pilot.app.query_one("#error")

        # Initially hidden
        assert error.has_class("hidden")

        # Show error
        pilot.app.show_error("Something went wrong")
        await pilot.pause()
        assert not error.has_class("hidden")
        assert "Something went wrong" in error.renderable

        # Dismiss
        pilot.app.dismiss_error()
        await pilot.pause()
        assert error.has_class("hidden")

Test Loading States

async def test_loading_indicator():
    """Test loading state shows during async work."""
    class MyApp(App):
        loading = False

        def compose(self):
            yield Static("Ready", id="status")

        async def load_data(self):
            self.loading = True
            self.query_one("#status").update("Loading...")
            # Simulate async work
            await asyncio.sleep(0.1)
            self.loading = False
            self.query_one("#status").update("Loaded")

    async with MyApp().run_test() as pilot:
        status = pilot.app.query_one("#status")

        # Before loading
        assert "Ready" in status.renderable

        # Start loading (don't await)
        task = asyncio.create_task(pilot.app.load_data())
        await pilot.pause(0.05)

        # During loading
        assert pilot.app.loading is True

        # After loading
        await task
        await pilot.pause()
        assert pilot.app.loading is False
        assert "Loaded" in status.renderable

Test Background Workers

async def test_worker_completion():
    """Test background worker completes and updates state."""
    class MyApp(App):
        data = None

        def compose(self):
            yield Static("", id="result")

        @work
        async def fetch_data(self):
            await asyncio.sleep(0.1)
            self.data = {"items": [1, 2, 3]}
            self.query_one("#result").update(str(self.data))

    async with MyApp().run_test() as pilot:
        # Trigger worker
        pilot.app.fetch_data()

        # Wait for completion
        await pilot.app.workers.wait_for_complete()

        # Verify result
        assert pilot.app.data == {"items": [1, 2, 3]}

Common Pitfalls

| Issue | Fix | |-------|-----| | Assertion before update | Add await pilot.pause() after interactions | | Worker not complete | Use await pilot.app.workers.wait_for_complete() | | Animation interference | Use await pilot.wait_for_animation() | | Race condition | Increase pause duration or use explicit waits |

See Also