Textual Test Fixtures
Reusable pytest fixtures for efficient Textual application testing.
Quick Reference
# conftest.py
import pytest
from typing import AsyncIterator
@pytest.fixture
async def app_pilot() -> AsyncIterator[tuple[MyApp, Pilot]]:
"""Provide app with pilot for testing."""
app = MyApp()
async with app.run_test() as pilot:
yield app, pilot
pytest Configuration
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # No @pytest.mark.asyncio needed
testpaths = ["tests"]
Core Fixture Patterns
1. App Factory Fixture
Create app instances with pilot access:
@pytest.fixture
async def calculator_app() -> AsyncIterator[tuple[CalculatorApp, Pilot]]:
"""Provide calculator app with pilot."""
app = CalculatorApp()
async with app.run_test() as pilot:
yield app, pilot
async def test_addition(calculator_app):
app, pilot = calculator_app
await pilot.press(*"2+2", "enter")
await pilot.pause()
assert app.query_one("#result").renderable == "4"
2. Parametrized App Factory
Support multiple app configurations:
@pytest.fixture
async def app_with_config(request) -> AsyncIterator[tuple[MyApp, Pilot]]:
"""Parametrized app fixture."""
config = getattr(request, "param", {})
app = MyApp(**config)
async with app.run_test() as pilot:
yield app, pilot
@pytest.mark.parametrize("app_with_config", [
{"theme": "dark"},
{"theme": "light"},
], indirect=True)
async def test_themed_app(app_with_config):
app, pilot = app_with_config
# Test with different themes
3. Custom Terminal Size Fixture
Test responsive layouts:
@pytest.fixture
async def large_terminal() -> AsyncIterator[tuple[MyApp, Pilot]]:
"""App with large terminal for testing sidebar visibility."""
app = MyApp()
async with app.run_test(size=(120, 40)) as pilot:
yield app, pilot
@pytest.fixture
async def small_terminal() -> AsyncIterator[tuple[MyApp, Pilot]]:
"""App with small terminal for testing compact layout."""
app = MyApp()
async with app.run_test(size=(60, 20)) as pilot:
yield app, pilot
4. Snapshot Fixture with Common Setup
Disable animations for stable snapshots:
@pytest.fixture
def snap_compare_stable(snap_compare):
"""snap_compare with animations disabled."""
def wrapper(app, **kwargs):
original_run_before = kwargs.get("run_before")
async def run_before(pilot):
# Disable animations
for widget in pilot.app.query("*"):
widget.can_animate = False
# Run user's setup
if original_run_before:
await original_run_before(pilot)
kwargs["run_before"] = run_before
return snap_compare(app, **kwargs)
return wrapper
def test_stable_snapshot(snap_compare_stable):
assert snap_compare_stable(MyApp())
Mock Fixtures
Mock API Client
from unittest.mock import AsyncMock, patch
@pytest.fixture
def mock_api():
"""Mock external API calls."""
with patch("myapp.api.fetch_data", new_callable=AsyncMock) as mock:
mock.return_value = {"users": [{"name": "Alice"}]}
yield mock
async def test_data_loading(app_pilot, mock_api):
app, pilot = app_pilot
await pilot.press("r") # Trigger refresh
await pilot.app.workers.wait_for_complete()
mock_api.assert_called_once()
assert len(app.query(".user-item")) == 1
Mock Database
from unittest.mock import MagicMock
@pytest.fixture
def mock_db():
"""Mock database connection."""
mock = MagicMock()
mock.query.return_value = [
{"id": 1, "name": "Task 1"},
{"id": 2, "name": "Task 2"},
]
with patch("myapp.db.get_connection", return_value=mock):
yield mock
Mock Time (Stable Timestamps)
from unittest.mock import patch
from datetime import datetime
@pytest.fixture
def frozen_time():
"""Freeze time for deterministic tests."""
fixed = datetime(2025, 1, 1, 12, 0, 0)
with patch("myapp.datetime") as mock_dt:
mock_dt.now.return_value = fixed
mock_dt.side_effect = lambda *args, **kw: datetime(*args, **kw)
yield fixed
Mock Environment Variables
import os
from unittest.mock import patch
@pytest.fixture
def mock_env():
"""Set test environment variables."""
env = {
"API_KEY": "test-key",
"DEBUG": "true",
}
with patch.dict(os.environ, env):
yield env
conftest.py Organization
# tests/conftest.py
"""Shared fixtures for all tests."""
import pytest
from typing import AsyncIterator
from unittest.mock import AsyncMock, patch
from myapp import MyApp
from textual.pilot import Pilot
# === App Fixtures ===
@pytest.fixture
async def app_pilot() -> AsyncIterator[tuple[MyApp, Pilot]]:
"""Standard app with pilot."""
app = MyApp()
async with app.run_test() as pilot:
yield app, pilot
@pytest.fixture
async def app_large() -> AsyncIterator[tuple[MyApp, Pilot]]:
"""App with large terminal."""
app = MyApp()
async with app.run_test(size=(120, 40)) as pilot:
yield app, pilot
# === Mock Fixtures ===
@pytest.fixture
def mock_api():
"""Mock API client."""
with patch("myapp.api.client", new_callable=AsyncMock) as mock:
yield mock
@pytest.fixture
def mock_settings():
"""Mock settings/config."""
with patch("myapp.settings") as mock:
mock.debug = True
mock.api_url = "http://test"
yield mock
# === Snapshot Fixtures ===
@pytest.fixture
def snap_compare_stable(snap_compare):
"""Snapshot comparison with animations disabled."""
def wrapper(app, **kwargs):
async def setup(pilot):
for w in pilot.app.query("*"):
w.can_animate = False
kwargs.setdefault("run_before", setup)
return snap_compare(app, **kwargs)
return wrapper
Test File Organization
tests/
├── conftest.py # Shared fixtures
├── unit/
│ ├── conftest.py # Unit-specific fixtures
│ ├── test_widgets.py
│ └── test_models.py
├── integration/
│ ├── conftest.py # Integration-specific fixtures
│ └── test_workflows.py
└── snapshot/
├── conftest.py # Snapshot-specific fixtures
├── test_layouts.py
└── __snapshots__/ # Committed SVG baselines
Advanced Patterns
Fixture Composition
@pytest.fixture
async def authenticated_app(app_pilot, mock_api):
"""App with authenticated user."""
app, pilot = app_pilot
mock_api.login.return_value = {"token": "test-token"}
# Perform login
await pilot.press(*"testuser", "tab", *"password", "enter")
await pilot.pause()
return app, pilot
Async Context Manager Fixture
from contextlib import asynccontextmanager
@asynccontextmanager
async def app_context(config=None):
"""Reusable app context manager."""
app = MyApp(**(config or {}))
async with app.run_test() as pilot:
yield app, pilot
@pytest.fixture
async def default_app():
async with app_context() as (app, pilot):
yield app, pilot
@pytest.fixture
async def debug_app():
async with app_context({"debug": True}) as (app, pilot):
yield app, pilot
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Fixture not async | Use async def for fixtures using run_test() |
Missing yield | Use yield not return in async context fixtures |
| Fixture scope wrong | Default to function scope for Textual apps |
| Mock not cleaned up | Use context managers (with patch(...)) |
See Also
- textual-testing - Core testing with Pilot
- textual-snapshot-testing - Visual regression testing
- textual-test-patterns - Testing recipes by scenario