textual-test-patterns
SKILL.md
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
- textual-testing - Core Pilot API reference
- textual-test-fixtures - Fixture patterns
- textual-snapshot-testing - Visual regression
Weekly Installs
3
Repository
dawiddutoit/cus…m-claudeFirst Seen
Feb 24, 2026
Security Audits
Installed on
mcpjam3
gemini-cli3
claude-code3
junie3
windsurf3
zencoder3