Textual Testing Reference#
Reference guide for testing Textual TUI applications in NexusLIMS.
Overview#
Textual provides built-in testing capabilities for headless execution of TUI apps. Tests use the run_test() method and the Pilot API to simulate user interactions.
Basic Test Structure#
Async Context Manager Pattern#
All Textual tests use async context managers:
import pytest
from my_app import MyApp
async def test_my_app():
"""Test basic app functionality."""
app = MyApp()
async with app.run_test() as pilot:
# Test code here
assert app.some_state == expected_value
Pytest Configuration#
Configure pytest for async tests in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto" # Avoids need for @pytest.mark.asyncio on every test
The Pilot API#
The Pilot object is your primary interface for controlling the app during tests.
Keyboard Input#
Pressing Single Keys#
await pilot.press("r") # Regular key
await pilot.press("enter") # Special key
await pilot.press("escape") # Another special key
await pilot.press("tab") # Tab key
Typing Sequences#
await pilot.press("h", "e", "l", "l", "o") # Type "hello"
Key Combinations (Modifiers)#
await pilot.press("ctrl+c") # Ctrl+C
await pilot.press("shift+tab") # Shift+Tab
await pilot.press("ctrl+s") # Ctrl+S
Common Special Keys#
"enter"- Enter/Return"escape"- Escape"tab"- Tab"space"- Space bar"backspace"- Backspace"delete"- Delete"up","down","left","right"- Arrow keys"home","end"- Home/End"pageup","pagedown"- Page Up/Down
Mouse Interactions#
Clicking Widgets#
# Click by CSS selector
await pilot.click("#my-widget-id")
await pilot.click(".my-widget-class")
await pilot.click("Button") # By widget type
# Click at screen coordinates
await pilot.click() # (0, 0)
await pilot.click(offset=(10, 5)) # (10, 5)
# Click with offset relative to widget
await pilot.click("#widget-id", offset=(5, 2))
Click Variations#
# Double-click
await pilot.click("#widget-id", times=2)
# Triple-click
await pilot.click("#widget-id", times=3)
# Click with modifiers
await pilot.click("#widget-id", shift=True)
await pilot.click("#widget-id", control=True)
await pilot.click("#widget-id", meta=True)
Hovering#
await pilot.hover("#widget-id") # Trigger hover effects
await pilot.hover(".button") # By class
Screen Configuration#
Custom Terminal Size#
Set terminal dimensions for testing responsive layouts:
async with app.run_test(size=(100, 50)) as pilot:
# Test with 100 columns × 50 lines
pass
Default size: 80 columns × 24 lines
Testing Different Sizes#
@pytest.mark.parametrize("size", [
(80, 24), # Standard
(120, 40), # Large
(40, 15), # Small
])
async def test_responsive_layout(size):
app = MyApp()
async with app.run_test(size=size) as pilot:
# Verify layout adapts correctly
pass
Synchronization and Timing#
Waiting for Message Processing#
The pause() method waits for all pending messages to be processed:
await pilot.pause() # Wait for message queue to clear
Adding Delays#
await pilot.pause(delay=0.5) # Wait 0.5 seconds before checking queue
When to use:
After actions that trigger async operations
When state changes aren’t immediate
Before assertions on app state
After navigation or screen changes
Example Pattern#
async def test_button_click():
app = MyApp()
async with app.run_test() as pilot:
# Click button
await pilot.click("#submit-button")
# Wait for async processing
await pilot.pause()
# Now assert on updated state
assert app.is_submitted is True
Accessing App State#
Query Widgets#
Use Textual’s query API to find and inspect widgets:
async def test_widget_state():
app = MyApp()
async with app.run_test() as pilot:
# Get single widget
button = app.query_one("#my-button", Button)
assert button.label == "Click Me"
# Get multiple widgets
inputs = app.query(Input)
assert len(inputs) == 3
# Check if widget exists
assert app.query("#my-widget").first() is not None
Access App Properties#
async def test_app_properties():
app = MyApp()
async with app.run_test() as pilot:
# Direct property access
assert app.current_screen.name == "main"
assert app.some_state_variable == expected_value
Snapshot Testing (Visual Regression)#
The pytest-textual-snapshot plugin captures SVG screenshots for visual regression testing.
Installation#
uv add --dev pytest-textual-snapshot
Basic Usage#
def test_app_appearance(snap_compare):
"""Test visual appearance of app."""
assert snap_compare("path/to/app.py")
Advanced Options#
def test_interactive_snapshot(snap_compare):
"""Test appearance after user interaction."""
assert snap_compare(
"path/to/app.py",
press=["tab", "enter", "down"], # Simulate key presses
terminal_size=(100, 40), # Custom size
)
Using run_before for Setup#
async def setup_app(pilot):
"""Setup function to run before snapshot."""
await pilot.click("#login-button")
await pilot.pause()
await pilot.press("username")
await pilot.press("tab")
await pilot.press("password")
def test_login_form(snap_compare):
"""Test login form after interaction."""
assert snap_compare(
"path/to/app.py",
run_before=setup_app
)
Snapshot Workflow#
First run (no baseline): Test fails and generates HTML report
uv run pytest tests/test_my_app.py
Review snapshots: Open the generated HTML report to verify output looks correct
Update baseline: Save the snapshots as the new baseline
uv run pytest --snapshot-update tests/test_my_app.py
Future runs: Tests compare against saved snapshots and fail on differences
Review diffs: When tests fail, check HTML report for visual differences
Snapshot Storage#
Snapshots stored in
tests/__snapshots__/directoryCommitted to version control
Each test function gets its own snapshot file
Testing Patterns#
Testing User Workflows#
async def test_complete_workflow():
"""Test multi-step user workflow."""
app = MyApp()
async with app.run_test() as pilot:
# Step 1: Navigate to form
await pilot.press("f")
await pilot.pause()
# Step 2: Fill form
form = app.query_one("#user-form")
await pilot.click("#name-input")
await pilot.press("J", "o", "h", "n")
await pilot.press("tab")
await pilot.press("D", "o", "e")
# Step 3: Submit
await pilot.click("#submit")
await pilot.pause()
# Step 4: Verify result
assert app.current_screen.name == "success"
Testing Data Tables#
async def test_data_table():
"""Test data table interactions."""
app = MyApp()
async with app.run_test() as pilot:
table = app.query_one(DataTable)
# Verify initial state
assert table.row_count == 5
# Navigate and select
await pilot.press("down", "down") # Move to row 2
await pilot.press("enter") # Select row
await pilot.pause()
# Verify selection
assert table.cursor_row == 2
assert app.selected_item is not None
Testing Input Validation#
async def test_input_validation():
"""Test form input validation."""
app = MyApp()
async with app.run_test() as pilot:
# Enter invalid data
await pilot.click("#email-input")
await pilot.press("i", "n", "v", "a", "l", "i", "d")
await pilot.press("tab")
await pilot.pause()
# Check validation error
error = app.query_one("#email-error")
assert error.display is True
assert "invalid email" in error.renderable.lower()
Testing Modals/Dialogs#
async def test_confirmation_dialog():
"""Test modal dialog interaction."""
app = MyApp()
async with app.run_test() as pilot:
# Trigger dialog
await pilot.click("#delete-button")
await pilot.pause()
# Verify dialog appears
dialog = app.query_one("#confirm-dialog")
assert dialog.display is True
# Confirm action
await pilot.click("#confirm-yes")
await pilot.pause()
# Verify action completed
assert app.item_deleted is True
assert dialog.display is False
Testing Database-Dependent TUIs#
For NexusLIMS TUI apps that interact with the database:
import pytest
from nexusLIMS.db import models
from nexusLIMS.tui.apps.instruments import InstrumentManagerApp
async def test_instrument_list(test_db_session):
"""Test instrument list display."""
# Setup: Add test instruments to DB
instrument = models.Instrument(
name="Test SEM",
harvester="nemo",
filestore_path="/test/path",
# ... other required fields
)
test_db_session.add(instrument)
test_db_session.commit()
# Test app
app = InstrumentManagerApp()
async with app.run_test() as pilot:
await pilot.pause() # Wait for DB query
table = app.query_one(DataTable)
assert table.row_count >= 1
# Verify data displayed
cells = [str(cell) for row in table.rows for cell in row]
assert "Test SEM" in cells
Common Assertions#
Widget State#
# Visibility
assert widget.display is True
assert widget.disabled is False
# Content
assert button.label == "Submit"
assert input.value == "expected text"
# Focus
assert app.focused == input_widget
Screen State#
# Current screen
assert app.current_screen.name == "main"
assert isinstance(app.current_screen, MyScreen)
# Query results
assert app.query("#widget-id").first() is not None
assert len(app.query(".button")) == 3
Best Practices#
Use descriptive test names:
test_user_can_submit_form_with_valid_data()Test both keyboard and mouse paths: Users may use either interaction method
Always pause after async actions:
await pilot.click("#button") await pilot.pause() # ✓ Good assert app.state == expected # Now state is updated
Test with different screen sizes: Ensure responsive layouts work correctly
Isolate tests: Each test should set up its own app instance
Test edge cases:
Empty states (no data)
Maximum data (full tables)
Invalid input
Error conditions
Use fixtures for common setup:
@pytest.fixture async def app_with_data(): app = MyApp() # Setup data return app
Keep tests focused: One test should verify one specific behavior
Use snapshot tests for visual regressions: Catch unintended UI changes
Test accessibility: Verify keyboard navigation works throughout the app
Debugging Tests#
Print App State#
async def test_debug():
app = MyApp()
async with app.run_test() as pilot:
# Print widget tree
print(app.query("*"))
# Save screenshot for inspection
await pilot.pause()
# Screenshot saved automatically on failure
Interactive Debugging#
Use breakpoints in async tests:
async def test_with_breakpoint():
app = MyApp()
async with app.run_test() as pilot:
await pilot.click("#button")
breakpoint() # Inspect app state here
await pilot.pause()
Check Message Queue#
async def test_message_queue():
app = MyApp()
async with app.run_test() as pilot:
await pilot.press("enter")
# If state isn't updating, add pause:
await pilot.pause()
# Now check state again
NexusLIMS TUI Testing Examples#
Testing Instrument Manager#
async def test_instrument_manager_filter():
"""Test filtering instruments by name."""
app = InstrumentManagerApp()
async with app.run_test() as pilot:
# Type in filter input
await pilot.click("#filter-input")
await pilot.press("S", "E", "M")
await pilot.pause()
# Verify filtered results
table = app.query_one(DataTable)
visible_rows = [r for r in table.rows if r.visible]
for row in visible_rows:
assert "SEM" in str(row)
Testing Validation#
async def test_instrument_form_validation():
"""Test instrument form validates required fields."""
app = InstrumentManagerApp()
async with app.run_test() as pilot:
# Try to submit empty form
await pilot.click("#add-instrument")
await pilot.pause()
await pilot.click("#submit")
await pilot.pause()
# Should show validation errors
errors = app.query(".validation-error")
assert len(errors) > 0