post

React Testing ohne Theater

React-Tests zahlen sich aus, wenn sie Nutzerverhalten prüfen statt Implementierungsdetails – das entkoppelt Tests vom Umbau und macht Refactoring wieder billig.

≈ 8 Min. Lesezeit

Die meisten React-Testsuites, die ich in Reviews sehe, sind teuer und trotzdem wertlos. Teuer, weil jede Komponente von Mocks umstellt ist. Wertlos, weil sie beim ersten Refactoring reihenweise rot werden, obwohl die Anwendung fachlich unverändert funktioniert. Das ist das Theater, das ich meine: viel Bühne, wenig Aussage. Ein Test, der nur bestätigt, dass der Code so aussieht, wie er gerade geschrieben ist, prüft nichts – er zementiert eine Momentaufnahme.

Der Anlass für diesen Beitrag ist banal: eine kleine Vite-React-App aus meinem Workshop-Repo introduction-react, ein einziger Test, eine sichtbare Zeile im UI. Interessant ist nicht die Zeile, sondern die Grenze, die sie zieht – zwischen dem, was ein Nutzer erlebt, und dem, wie ich es intern gebaut habe. Diese Grenze ist die eigentliche Architekturentscheidung beim Testen. Wer sie sauber zieht, bekommt Tests, die Umbauten überleben. Wer sie verfehlt, bekommt eine Suite, die bei jedem Sprint mitgepflegt werden muss und trotzdem keine Regression fängt.

Die These: Verhalten schlägt Implementierung

Ein Test hat genau zwei Aufgaben. Er soll ein reales Fehlverhalten fangen, bevor es beim Nutzer ankommt. Und er soll mich nicht daran hindern, den Code umzubauen. Diese beiden Ziele stehen in Spannung, und die Auflösung heißt: teste, was der Nutzer beobachtet, nicht, wie die Komponente es intern erreicht.

Der Unterschied wird an zwei Varianten desselben Tests sichtbar. Zuerst die brüchige Variante, die an die Innerei koppelt:

// Brittle: couples the test to internal state and structure
test("increments the count state", () => {
  const { result } = renderHook(() => useCounter());

  act(() => result.current.increment());

  expect(result.current.count).toBe(1);
});

Dieser Test kennt den Hook, seinen Rückgabewert und dass „increment" so heißt. Sobald ich useCounter durch einen Reducer, einen Store oder eine ganz andere Mechanik ersetze, bricht er – obwohl der Zähler im Browser weiter tut, was er soll. Der Test misst meine Implementierung, nicht das Produkt.

Jetzt dieselbe Zusicherung über das beobachtbare Verhalten:

// Robust: asserts what the user actually sees
test("shows the new count after a click", async () => {
  const user = userEvent.setup();
  render(<Counter />);

  await user.click(screen.getByRole("button", { name: /increment/i }));

  expect(screen.getByRole("status")).toHaveTextContent("Count: 1");
});

Dieser Test weiß nichts über Hooks, State oder Struktur. Er weiß, dass es einen Button gibt, den man klickt, und dass danach eine Zahl steht. Genau das ist der Vertrag mit dem Nutzer. Ich kann die gesamte Innerei austauschen, und solange dieser Vertrag hält, bleibt der Test grün. Das ist der Punkt, an dem ein Test von einer Umbau-Bremse zu einem Sicherheitsnetz wird.

Wo die Grenze physisch verläuft

Diese Grenze ist keine Haltung, sie ist im Query-Design der Testing Library eingebaut. Die Reihenfolge der Queries ist eine Trade-off-Ansage: getByRole und getByLabelText zuerst, getByText danach, getByTestId als letzte Rettung. Das ist kein Stilgeschmack. Eine Rollen-Query prüft nebenbei die Accessibility – wenn der Test das Element über seine Rolle findet, findet ein Screenreader es auch. Eine data-testid prüft nur, dass ich mir selbst ein Etikett geklebt habe.

// Prefer accessible queries – they double as an a11y assertion
screen.getByRole("searchbox");
screen.getByRole("button", { name: /search/i });

// testid is an escape hatch, not the default
screen.getByTestId("product-list");

Die praktische Konsequenz: Ich schreibe Komponenten so, dass sie über Rollen und Labels ansprechbar sind – semantische Elemente, aria-label wo nötig, echte <button> statt geklickter <div>. Der Test zwingt mich zu barrierefreiem Markup, weil unzugängliches Markup schlicht schwer testbar ist. Das ist die Art von Kopplung, die ich will: Testbarkeit und Produktqualität ziehen in dieselbe Richtung.

Die testid ist damit nicht verboten, sie ist nur begründungspflichtig. Legitim ist sie, wenn ein Container keine sinnvolle Rolle trägt – ein Layout-Wrapper, ein Chart-Canvas, eine Liste ohne semantisches Pendant. Dann ist ein bewusst gesetztes data-testid ehrlicher als eine erzwungene Rolle, die es im Produkt gar nicht gibt. Der Unterschied ist die Reihenfolge: Ich greife zur testid, weil die zugängliche Query nicht passt, nicht weil sie mir zu umständlich war. Diese Reihenfolge ist der ganze methodische Gehalt der Query-Prioritäten – sie macht die Ausnahme sichtbar, statt sie zur Regel werden zu lassen.

Der Fehlerpfad ist die halbe Arbeit

Der glückliche Pfad sieht in fast jeder App im Demo-Modus sauber aus. Geld verdient man mit dem Rest: leerer Antwort, Timeout, 500er, doppeltem Klick, langsamer Verbindung. Bei React-Anwendungen liegt dieser Rest fast immer an der Netzgrenze, und genau dort setze ich den Mock an – nicht am Hook, nicht am fetch-Aufruf, sondern an der HTTP-Schicht. Mit Mock Service Worker läuft die Komponente durch ihre echte Datenlogik, nur die Antwort ist kontrolliert.

// The happy path: results the API returned actually render
test("renders products returned by the API", async () => {
  const user = userEvent.setup();
  render(<ProductSearch />);

  await user.type(screen.getByRole("searchbox"), "react");
  await user.click(screen.getByRole("button", { name: /search/i }));

  expect(await screen.findByRole("listitem"))
    .toHaveTextContent("React Handbook");
});

Und die Kehrseite, die im Betrieb den Unterschied macht:

// The failure path: does the UI degrade gracefully?
test("shows an error message when the request fails", async () => {
  server.use(
    http.get("/api/products", () => HttpResponse.error()),
  );

  const user = userEvent.setup();
  render(<ProductSearch />);

  await user.click(screen.getByRole("button", { name: /search/i }));

  expect(await screen.findByRole("alert"))
    .toHaveTextContent(/something went wrong/i);
});

Der zweite Test ist wichtiger als der erste. Er beschreibt, was passiert, wenn das Backend wegbricht – ob die App eine Fehlermeldung zeigt oder in einen leeren, verwirrenden Zustand fällt. Dass ich den Mock über server.use pro Test setze, ist Absicht: Der Default ist der glückliche Pfad, die Störung ist eine explizite, lesbare Abweichung. Wer den Fehlerpfad nicht testet, hat die Hälfte des Produkts nicht getestet. Der Nutzer erlebt beide.

Zum Fehlerpfad gehört auch die async-Disziplin. findBy* wartet auf das Erscheinen eines Elements, waitFor auf das Eintreten einer Bedingung – aber nur auf beobachtbare Zustände, nicht auf setTimeout-Raten. Wer stattdessen feste Wartezeiten einbaut, tauscht Flakiness gegen Langsamkeit und behält am Ende beides.

Specification by Example: Tests, die die Fachsprache sprechen

Der stabilste Test benennt eine fachliche Absicht, keinen technischen Ablauf. Ein Testname wie „calls dispatch with INCREMENT" beschreibt Mechanik und altert mit jeder Umbenennung. „shows the new count after a click" beschreibt ein Versprechen an den Nutzer und altert nur, wenn sich das Versprechen ändert. Das ist der Kern von Specification by Example: Das Beispiel ist gleichzeitig Fachdokumentation, Erwartung und automatisierbare Prüfung – drei Dinge, die sonst auseinanderdriften.

Praktisch heißt das, ich schreibe die describe- und test-Blöcke so, dass ein Product Owner sie lesen und bestätigen könnte, ohne den Code zu kennen:

describe("Product search", () => {
  test("shows results matching the query", async () => { /* ... */ });
  test("shows an empty state when nothing matches", async () => { /* ... */ });
  test("shows an error when the service is unavailable", async () => { /* ... */ });
});

Diese Suite ist eine Spezifikation. Sie sagt, was das Feature kann und was es bei Störung tut. Ein Reviewer sieht sofort, ob ein Fall fehlt – der leere Zustand, der Fehlerfall – und kann fachlich widersprechen, ohne die Implementierung zu lesen. Genau darin liegt der methodische Hebel: Missverständnisse werden im Test sichtbar, nicht erst im Betrieb. Das verschiebt die teure Klärung vom Ende der Kette an den Anfang.

Die Architektur der Suite: Schichten statt Masse

Nicht jeder Test kostet gleich viel und bringt gleich viel. Eine tragfähige React-Suite ist geschichtet. Reine Logik – Formatierung, Validierung, Reducer – prüfe ich als schnelle Unit-Tests ohne DOM. Das Verhalten der Komponenten prüfe ich als Component-Tests mit gemockter Netzgrenze; das ist die Schicht, in die ich das meiste Vertrauen und die meiste Zeit investiere. Und ich halte eine kleine Zahl echter Integrationsläufe, die die reale Verdrahtung einmal durchspielen, statt sie zu simulieren.

flowchart TD
  A[User intent<br/>Specification by Example] --> B[Component tests<br/>role and label queries]
  B --> C[Network boundary<br/>mocked with MSW]
  C --> D[Few integration runs<br/>real wiring]
  D --> E[Refactor<br/>tests stay green]
  E --> A

Die Verteilung ist eine bewusste Investitionsentscheidung, kein Dogma. Viele langsame End-to-End-Tests geben ein warmes Gefühl und eine kalte Pipeline: Sie sind langsam, flaky und teuer im Unterhalt, und wenn sie rot werden, weiß niemand sofort warum. Wenige, gezielte Integrationsläufe plus eine dichte, schnelle Component-Schicht fangen mehr echte Fehler pro investierter Minute. Der Testlauf gehört in die schnelle Feedbackschleife des Entwicklers, nicht nur in die nächtliche CI – deshalb Vitest und jsdom statt echtem Browser, wo immer die Aussage das hergibt.

Warum sich das rechnet

Testen ist kein Selbstzweck und keine Gewissensberuhigung, es ist eine Investition mit messbarer Rendite. Der Ertrag ist Änderungsgeschwindigkeit. Eine Suite, die an Verhalten koppelt, erlaubt Refactoring ohne Angst: Ich baue useCounter zu einem Store um, die Tests bleiben grün, ich weiß, dass der Vertrag hält. Eine Suite, die an Implementierung koppelt, macht jedes Refactoring zu einem zweiten Projekt – erst Code umbauen, dann Dutzende Tests nachziehen. Die zweite Sorte bringt Teams dazu, Umbauten aufzuschieben. So entsteht technische Schuld nicht trotz, sondern wegen der Tests.

Die Wirkung ist an drei Stellen sichtbar. Der Review wird kürzer, weil die Testnamen die Fachfälle auflisten und der Reviewer die Abdeckung sieht, statt sie zu erraten. Die Regressionsrate sinkt, weil der Fehlerpfad mitgeprüft wird und nicht erst der Support ihn entdeckt. Und der Entwicklungszyklus verkürzt sich, weil die schnelle Testschicht lokal in Sekunden läuft und Fehler an der Tastatur fängt statt in der Pipeline. Weniger Reibung, kürzere Zyklen, höhere Qualität – das ist kein Nebeneffekt guter Tests, es ist der einzige Grund, sie zu schreiben.

Typische Fehler

Der erste Fehler ist, das Werkzeug mit der Lösung zu verwechseln. Testing Library erzeugt keine guten Tests, sie macht schlechte nur schwerer. Man kann auch mit ihr an testids kleben, den State über Umwege inspizieren und die Netzgrenze am falschen Punkt kappen. Das Tool drückt eine Grenze aus, es zieht sie nicht.

Der zweite Fehler ist, Coverage mit Sicherheit zu verwechseln. Neunzig Prozent Zeilenabdeckung sagen nichts darüber, ob der Fehlerpfad geprüft ist. Eine Suite, die jeden glücklichen Pfad abdeckt und keinen Störfall, hat hohe Zahlen und niedrigen Wert. Ich zähle lieber Fehlerfälle als Zeilen.

Der dritte Fehler ist, alles zu mocken. Wer den Hook mockt, den Store mockt und die Kindkomponente mockt, testet am Ende nur noch die Mocks. Je tiefer der Mock sitzt, desto weniger echtes Verhalten läuft durch den Test. Die Netzgrenze ist fast immer der richtige Schnitt – dahinter beginnt fremder Code, davor liegt mein Produkt.

Woran ich einen Test prüfe

Bevor ein Test in die Suite darf, stelle ich ihm drei Fragen. Würde er rot, wenn das Feature fachlich kaputtginge? Wenn nein, prüft er nichts. Bleibt er grün, wenn ich die Implementierung umbaue, ohne das Verhalten zu ändern? Wenn nein, ist er eine Umbau-Bremse. Und beschreibt sein Name eine Nutzerabsicht, die ein Fachexperte bestätigen könnte? Wenn nein, altert er mit dem nächsten Rename.

Ein Test, der alle drei Fragen besteht, ist billig im Unterhalt und teuer nur einmal – beim Schreiben. Ein Test, der eine davon reißt, wird jeden Sprint erneut bezahlt. Über die Lebensdauer eines Projekts entscheidet diese Differenz darüber, ob die Suite Vertrauen schafft oder Wartungslast.

Weiterführende Quellen

Das Repo zeigt die kleine Vite-React-App und die Testbeispiele im Kontext einer vollständigen Lernkurve vom Baustein bis zur Architektur. Die Docs von Testing Library und Vitest helfen beim Gegenprüfen von Query-Prioritäten und async-Verhalten – die eigentliche Aussage bleibt aber die Entscheidung, wo im konkreten Beispiel die Grenze zwischen Verhalten und Implementierung verläuft.

Schluss

React Testing wird wertvoll, wenn Tests eine Frage beantworten, die den Nutzer interessiert: Tut die Anwendung, was sie soll, auch wenn etwas schiefgeht? Alles andere – Mock-Türme, testid-Ketten, Coverage-Prozente – ist Bühne. Der Test, der einen Klick simuliert und eine sichtbare Zahl prüft, ist unscheinbar. Aber er überlebt jeden Umbau, benennt einen Fachfall und fängt eine echte Regression. Genau darin liegt der Hebel für Entwickler, Architekten und technische Entscheider: nicht im Toolnamen, sondern in der Grenze, die man mit ihm zieht – und in den kürzeren Zyklen, die daraus folgen.

Kommentare