Du kannst eine Test-Strategie für eine TUI gezielt entwerfen, statt sie zufällig wachsen zu lassen. Du kennst die Stufen — statisch, Unit, Pilot-Smoke, Content-Rendering, Manuell — und du weißt für jede Bug-Klasse, welche Stufe sie am billigsten erwischt.
Konkret nach Tier 3: Du hast einen eigenen Test-Plan für eine reale App, der explizit zuordnet, welche Stufe welche Garantie liefert. Und du schreibst Pilot-Smoke-Tests mit app.run_test(), die nicht nur das Mount, sondern den angezeigten Inhalt prüfen.
In einer Session entstand:
Drei Stufen, eine Suite. Die Pyramide ist nicht da, weil sie hübsch ist — jede Stufe deckt eine Bug-Klasse ab, die die anderen nicht sehen können.
| Stufe | Beweist | Kosten | Übersieht |
|---|---|---|---|
| Statisch (AST) | Strukturelle Invarianten der Datei | ~0,01 s/Datei | jeden Logikfehler |
| Unit | Eine Funktion bei festgelegten Eingaben | ~0,01 s/Test | Integration zwischen Komponenten |
| Pilot-Smoke | Die ganze App mountet und reagiert auf Tasten | ~1–3 s/Test | dass der Inhalt richtig dargestellt wird |
| Content-Rendering | Was im Widget am Bildschirm steht, stimmt | ~1–3 s/Test | terminalspezifische Rendering-Eigenheiten |
| Manuell | „Es fühlt sich richtig an" | Minuten | alles, was du nicht erneut prüfst |
Der entscheidende Begriff: Jede Stufe hat eine eigene Bug-Klasse, gegen die sie blind ist. Deshalb stapelt man sie. Wer nur Pilot-Tests schreibt, übersieht den vergessenen Aufruf nach einer Umbenennung — den ein AST-Wächter in 0,3 Sekunden gemeldet hätte. Wer nur AST-Wächter schreibt, übersieht, dass das Overlay zwar mountet, aber den falschen Text zeigt.
Die untere Stufe gewinnt bei Reibung. Die obere Stufe gewinnt bei Bug-Realismus. Beides ist wahr. Beides hat seinen Platz.
Bereits in Lernreise 1 gebaut. Kurzform: parse die Datei, walk den Baum, prüf eine strukturelle Invariante. Vor jedem Commit aufrufbar, ~0,3 Sekunden für ein ganzes Repo.
Textual liefert eine eingebaute Test-Harness: app.run_test(). Sie startet die App ohne echtes Terminal — alle Events, kein Bildschirm.
# tests/test_screen_smoke.py
import asyncio
from myapp import MyApp
def test_app_starts_and_quits():
async def run():
async with MyApp().run_test() as pilot:
# App ist gemountet
assert pilot.app.screen.id == "main"
# Taste „q" simulieren
await pilot.press("q")
# App hat sauber beendet, weil kein Fehler flog
asyncio.run(run())
Warum asyncio.run statt @pytest.mark.asyncio? Eine Dependency weniger. pytest-asyncio ist ein Plugin mit eigenem Versions-Drift. Wenn deine Tests synchron bleiben können, indem sie einen Async-Block intern ausführen, brauchst du es nicht. Die iMenu-Suite hat sich bewusst dafür entschieden.
Dies ist der Test, den du wirklich willst, und der häufig fehlt:
def test_sessions_overlay_shows_session_names():
async def run():
async with SessionsOverlayApp().run_test() as pilot:
# Overlay öffnen
await pilot.press("s")
# Den angezeigten Inhalt holen
list_widget = pilot.app.query_one("#sessions-list")
content = list_widget.render().plain # ← echter String
# Behaupte etwas über den sichtbaren Text
assert "session-1" in content
assert "session-2" in content
asyncio.run(run())
.render().plain ist die Brücke vom Frontend zur Behauptung. Ein Mount-only-Test sagt: „das Widget existiert." Ein Content-Rendering-Test sagt: „das Widget zeigt, was es zeigen soll." Der zweite ist die ehrlichere Prüfung.
Letzte Stufe. Selten. Gezielt. Für Dinge, die du noch nicht weißt, wie sie zu testen wären — Look-and-Feel, Tasten-Choreografie, Farben in einem bestimmten Terminal.
Faustregel: Wenn du dieselbe manuelle Prüfung zwei Mal machst, hast du gerade einen automatisierten Test bezahlt, ohne ihn zu bekommen.
Pro Bug-Klasse, die dich schon mal gebissen hat (oder die du fürchtest), beantworte:
| Frage an die App | Welche Stufe ist die Antwort? |
|---|---|
| „Habe ich nach diesem Refactor tote Aufrufstellen?" | AST |
| „Tut diese reine Funktion bei diesem Input das Richtige?" | Unit |
| „Startet die App und antwortet überhaupt?" | Pilot-Smoke |
| „Zeigt das Overlay den richtigen Text?" | Content-Rendering |
| „Sieht die Tab-Navigation natürlich aus?" | Manuell — aber gezielt |
Deine Übung: Mach diese Tabelle für dein Projekt. Pro Zeile: welche Stufe ist die billigste, die diese Bug-Klasse erwischt? Wenn keine sie erwischt, weißt du, wo du als nächstes Tests bauen solltest.
Anti-Pattern A — alles auf einer Stufe:
Eine Suite aus 200 Unit-Tests und null Integrations-Tests. Jede Funktion ist grün, die App beim Start kaputt. Häufig bei Teams, die TDD missverstanden haben.
Anti-Pattern B — der Mount-Test als End-to-End:
def test_overlay_works():
async def run():
async with App().run_test() as pilot:
await pilot.press("s")
assert pilot.app.query_one("#sessions-list") is not None # ← reicht nicht!
asyncio.run(run())
Der Test ist grün, das Overlay zeigt eine leere Liste. Mount-only beweist nicht Inhalt. Geh eine Stufe höher zu .render().plain.
.render().plain ist die Brücke vom UI zur Behauptung. Mount-only ist die häufigste vermeidbare Lücke.asyncio.run statt pytest-asyncio spart eine Dependency, wenn dein Code-Stil es zulässt.grep übersah.tests/test_static_guards.py (5 AST-Wächter), tests/test_screen_smoke.py (21 Pilot-Smoke-Tests)Pilot und app.run_test()