Lernreise 3 — Testpyramide für TUIs

Was du nach dieser Reise kannst

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.

Anker — die iMenu-Suite

In einer Session entstand:

  • 5 AST-Wächter — 0,9 Sekunden, ohne dass die App startet
  • 21 Pilot-Smoke-Tests — ~65 Sekunden, mit echtem Mount und gerendertem Inhalt
  • 4 manuelle CLI-Verifikationen für die Subkommandos

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.


Tier 1 · Erkennen — Was beweist welche Stufe?

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 zwei Kostenarten

  • Test-Schreiben ist Investition — einmal pro Bug-Klasse.
  • Test-Laufen ist Reibung — bei jedem Commit, jedem CI-Lauf, jedem Pre-Commit-Hook.

Die untere Stufe gewinnt bei Reibung. Die obere Stufe gewinnt bei Bug-Realismus. Beides ist wahr. Beides hat seinen Platz.


Tier 2 · Üben — Schreib jede Stufe

Stufe 1 — der AST-Wächter

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.

Stufe 2 — der Pilot-Smoke-Test

Textual liefert eine eingebaute Test-Harness: app.run_test(). Sie startet die App ohne echtes Terminal — alle Events, kein Bildschirm.

PYTHON
# 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.

Stufe 3 — der Content-Rendering-Test

Dies ist der Test, den du wirklich willst, und der häufig fehlt:

PYTHON
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.

Stufe 4 — die manuelle Verifikation

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.


Tier 3 · Übertragen — Entwirf deinen eigenen Plan

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.

Zwei Anti-Patterns, die du vermeidest

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:

PYTHON
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.


Was du jetzt weißt

  • Jede Test-Stufe ist gegen eine eigene Bug-Klasse blind. Stapeln ist deshalb keine Übervorsicht, sondern Notwendigkeit.
  • Test-Schreib-Kosten und Test-Lauf-Kosten sind verschiedene Größen. Beide gehören in die Strategie.
  • .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.

Weiterführende Reisen

Quellen & Anker

  • iMenu-Suite: tests/test_static_guards.py (5 AST-Wächter), tests/test_screen_smoke.py (21 Pilot-Smoke-Tests)
  • Textual-Dokumentation: Pilot und app.run_test()
  • Anker-Bug: das Overlay, das gemountet hat, aber nichts anzeigte — gefunden erst durch eine Content-Rendering-Behauptung