Komponenten testen
Kurz gesagt: Komponententests mit Testing Library und Vitest prüfen, was ein Mensch sieht: Sie finden Elemente nach Rolle, Label und Text und klicken wie ein Benutzer.
Problem
Der Test ist grün, der Button ist kaputt
Jemand hat einen Test für die Produktkarte geschrieben. Führ ihn aus.
Konsole
Der Bericht sagt „Tests: 1 bestanden, 0 fehlgeschlagen“. Öffne jetzt ProductCard.jsx: Der Button hat kein onClick. Der Kunde drückt „In den Warenkorb“, und nichts passiert, und der Test ist grün.
Der Test findet den Button über seine Klasse (.add) und prüft seinen Text. Niemand drückt ihn. Auch das Gegenteil passiert: Benenne die Klasse in btn-add um, und der Test schlägt fehl, während der Mensch vor dem Bildschirm keinen Unterschied sieht. Der Test folgt der Art, wie der Code geschrieben ist, nicht dem, was er tut.
Die Idee
Teste, was ein Mensch sieht
Wenn du im JavaScript-Kurs Tests geschrieben hast: Die Idee ist dieselbe, Code, der deinen Code aufruft und das Ergebnis vergleicht. Der Unterschied ist, dass das Ergebnis einer Komponente eine Seite ist. Also tut der Test, was ein Mensch davor tut:
- Zeichne die Komponente:
render. - Finde das Element so, wie ein Mensch es findet: nach Rolle und Name, nach Label, nach Text.
- Handle wie ein Mensch:
user.click,user.type. - Prüfe, was sichtbar ist:
expect(…).toBeInTheDocument().
Es gibt zwei Werkzeuge. Vitest führt die Tests aus (test, expect), und Testing Library zeichnet die Komponente und durchsucht die Seite. Das sind Komponententests.
import { render, screen } from '@testing-library/react';
render(<ProductCard product={bread} />);screen.getByRole('button', { name: 'In den Warenkorb' });Die Reihenfolge, in der Testing Library dir zum Suchen rät:
getByRolemit einem Namen: ein Button, ein Link, eine Überschrift, ein Feld. So sucht ein Screenreader, deshalb prüft der Test auch die Barrierefreiheit. Ein Button ohne Namen wird nicht gefunden.getByLabelText: ein Feld über sein Label.getByText: Text, der nicht in einem Button oder einer Überschrift steht.getByTestIderst am Ende, wenn nichts anderes funktioniert. Das Attributdata-testidist für den Menschen nicht sichtbar.
Sieh es dir an
Sieh, wie der Test läuft
Die vier Schritte an einer Karte. Danach derselbe Test gegen eine defekte Version.
test('der Button legt das Produkt in den Warenkorb', async () => { const user = userEvent.setup(); render(<ProductCard product={bread} />); const button = screen.getByRole('button', { name: 'In den Warenkorb', }); await user.click(button); expect(screen.getByText('Im Warenkorb: 1')) .toBeInTheDocument();});Schritt für Schritt, in Worten
- test() gibt einer Prüfung einen Namen. Vitest führt jeden Test in der Datei aus und meldet, welche bestanden haben.
- render zeichnet ProductCard in eine leere Seite, wie es der Browser täte. Keine App, kein Rest des Shops: nur diese Komponente.
- Der Test sucht den Button wie ein Mensch: die Rolle „button“ und den Namen „In den Warenkorb“. Die Klasse und die Stelle im Code spielen keine Rolle.
- user.click drückt den Button wie ein Mensch: Zeiger-Ereignisse, dann click. Die Komponente aktualisiert sich und zeigt „Im Warenkorb: 1“.
- expect prüft, was der Mensch sieht: Der Text steht auf der Seite. Der Test besteht.
- Jetzt derselbe Test gegen eine defekte Version: Der Button ist an nichts angeschlossen. Der Klick erreicht den Button, und nichts ändert sich.
- expect findet „Im Warenkorb: 1“ nicht, und der Test schlägt fehl. Grün beim richtigen Code und rot beim defekten: Auf einen solchen Test kannst du dich verlassen.
Der Test weiß nicht, ob der Code ein className hat, wie viele <div> um den Button stehen oder wie die Funktion heißt. Er weiß, was auf dem Bildschirm steht. Deshalb übersteht er ein Refactoring und schlägt bei einem Fehler fehl.
Probier es aus
Der erste echte Test
Zwei Tests: Die Karte zeigt Namen und Preis, und der Button legt in den Warenkorb. Führ sie aus. Entferne dann onClick={add} in ProductCard.jsx und führ noch einmal aus: Der zweite Test schlägt fehl.
Konsole
userEvent.setup() erzeugt einen „Benutzer“, und await user.click(…) klickt wie ein Mensch: Der Zeiger bewegt sich über den Button, drückt, lässt los, und erst dann kommt click. Jede Aktion von user gibt ein Promise zurück, deshalb ist der Test async und wartet mit await.
toBeInTheDocument() kommt von jest-dom: Prüfungen für die Seite wie toBeDisabled(), toHaveTextContent(), toHaveValue(). In der Sandbox sind sie eingebaut, und in einem Projekt schaltest du sie mit einer Zeile ein (siehe „Genauer, für Neugierige“).
Probier es aus
Finde den Fehler
Der Test schlägt mit „Unable to find an element with the text: Im Warenkorb: 1“ fehl, und die Komponente ist die richtige. Welche Zeile ist schuld?
Wähle die Zeile mit dem Fehler.
Zeile 11: user.click(…) ohne await. Der Klick ist versprochen, aber noch nicht passiert, wenn expect den Text sucht. Mit await user.click(…) wartet der Test auf den Klick und darauf, dass sich die Komponente aktualisiert.
Die Idee
Was die Karte dem Elternelement sagt: vi.fn()
Die Karte besitzt den Warenkorb nicht. Sie sagt dem Elternelement „füge dieses Produkt hinzu“ (die Lektion über gemeinsamen Zustand). Ein Test der Karte sollte nicht den ganzen Shop zeichnen, um das zu sehen. Statt eines Elternelements übergibst du vi.fn(): eine leere Funktion, die sich jeden Aufruf merkt. Eine solche Stellvertreter-Funktion heißt Mock.
const onAdd = vi.fn();render(<ProductCard product={bread} onAdd={onAdd} />);await user.click(screen.getByRole('button', { name: 'In den Warenkorb' }));expect(onAdd).toHaveBeenCalledTimes(1);expect(onAdd).toHaveBeenCalledWith(bread);not dreht die Prüfung um: expect(onAdd).not.toHaveBeenCalled() heißt „nie aufgerufen“.
Der Test prüft, dass die Karte genau dieses Produkt schickt. Was kommt in die Lücke?
test('der Button legt das Produkt in den Warenkorb', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProductCard product={bread} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: 'In den Warenkorb' }));
expect(onAdd).(bread);
});toHaveBeenCalledWith(bread): Die Funktion wurde mindestens einmal mit diesen Argumenten aufgerufen. Es vergleicht die Werte, also besteht auch ein Objekt mit denselben Feldern.
Probier es aus
Das ausverkaufte Produkt
Die Banitsa ist ausverkauft: Der Button muss deaktiviert sein, die Karte muss „Nicht auf Lager“ sagen, und ein Klick darf onAdd nicht aufrufen. Vervollständige den Test: Klick auf den Button und prüfe alle drei. „Prüfen“ führt deinen Test gegen die richtige Karte aus und danach gegen zwei defekte Versionen. Der Test muss bei der richtigen bestehen und bei jeder defekten fehlschlagen.
Konsole
Lösung
ProductCard.test.jsximport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProductCard from './ProductCard.jsx';
const banitsa = { id: 4, name: 'Banitsa mit Käse', price: 2.5, stock: 0 };
test('ein ausverkauftes Produkt wird nicht hinzugefügt', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ProductCard product={banitsa} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: 'In den Warenkorb' }));
expect(onAdd).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'In den Warenkorb' })).toBeDisabled();
expect(screen.getByText('Nicht auf Lager')).toBeInTheDocument();
});
ProductCard.jsximport { useState } from 'react';
import { money } from './money.js';
export default function ProductCard({ product, onAdd }) {
const [added, setAdded] = useState(0);
const soldOut = product.stock === 0;
function add() {
setAdded(added + 1);
onAdd?.(product);
}
return (
<article className="card">
<h3>{product.name}</h3>
<p>{money(product.price)}</p>
{soldOut && <p>Nicht auf Lager</p>}
<button type="button" className="add" disabled={soldOut} onClick={add}>In den Warenkorb</button>
{added > 0 && <p>Im Warenkorb: {added}</p>}
</article>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
await user.click(screen.getByRole('button', { name: 'In den Warenkorb' }));expect(onAdd).not.toHaveBeenCalled();expect(screen.getByRole('button', { name: 'In den Warenkorb' })).toBeDisabled();expect(screen.getByText('Nicht auf Lager')).toBeInTheDocument();Ein Klick auf einen deaktivierten Button erreicht die Komponente nicht, also wird onAdd nicht aufgerufen. Der Test findet beide Fehler: die Version, in der der Button nicht deaktiviert ist (der Klick ruft onAdd auf), und die Version ohne die Meldung. Ein Test, der nur die Meldung prüft, besteht gegen die erste. Deshalb lehnt die Prüfung ihn ab.
Die Idee
Daten, die später kommen: findBy
Die Liste lädt die Produkte aus der API. Zuerst sagt sie „Lädt…“, und die Produkte kommen einen Moment später. Der Test muss auf sie warten. Führ die zwei Tests aus: Der erste schlägt absichtlich fehl.
Konsole
Es gibt drei Arten von Abfragen:
getBy…sucht sofort. Wenn das Element nicht da ist, schlägt der Test fehl. Der erste Test schlägt genau so fehl: Die Antwort ist noch nicht angekommen.findBy…gibt ein Promise zurück und versucht es immer wieder, bis das Element erscheint. Erscheint es nicht rechtzeitig (in Testing Library standardmäßig nach 1 Sekunde), schlägt es fehl. Deshalb steht es mitawait.queryBy…gibtnullzurück, wenn es das Element nicht gibt, ohne fehlzuschlagen. Damit prüfst du, dass etwas weg ist:expect(screen.queryByText('Lädt…')).toBeNull().
Wartest du auf etwas, das kein Element ist (eine Funktion, die nach der Anfrage aufgerufen wird), nimmst du waitFor: await waitFor(() => expect(onLoad).toHaveBeenCalled()). Es führt die Prüfung aus, bis sie besteht oder die Zeit abläuft.
Genauer, für Neugierige
In einem echten Projekt. Die Pakete: npm install -D vitest jsdom @testing-library/user-event @testing-library/jest-dom und @testing-library/react @testing-library/dom. In vite.config.js fügst du test: { environment: 'jsdom' } hinzu: Die Tests laufen in Node, und jsdom gibt ihnen eine Seite. Am Anfang der Tests (oder in einer Datei aus setupFiles) schreibst du import '@testing-library/jest-dom/vitest', um toBeInTheDocument() und die anderen zu bekommen. Die Tests führst du mit npx vitest aus, und ihre Dateien sind *.test.jsx oder *.test.js neben der Komponente.
Aufräumen zwischen den Tests. Testing Library entfernt nach jedem Test von selbst, was es gezeichnet hat, wenn afterEach global ist. In Vitest ist es nur mit test: { globals: true } global. Sonst rufst du cleanup() in afterEach auf. In der Sandbox bekommt jeder Test eine leere Seite.
fireEvent und user. fireEvent.click(button) sendet ein einziges click-Ereignis. user.click(button) geht durch alles, was ein Mensch tut: der Zeiger, der Fokus, das Drücken. Ein Element mit pointer-events: none, das ein Mensch nicht drücken kann, weigert sich user mit einem Fehler zu drücken, während fireEvent das Ereignis sendet. Deshalb ist user näher an der Wahrheit.
End-to-End-Tests. Ein Komponententest zeichnet ein Stück. Ein End-to-End-Test (zum Beispiel mit Playwright) startet die ganze App in einem echten Browser und geht wie ein Kunde durch sie hindurch: Katalog, Warenkorb, Bestellung. Er findet Fehler zwischen den Teilen, ist aber langsamer. Deshalb gibt es viele Komponententests und wenige End-to-End-Tests, für die wichtigsten Wege.
Was du nicht testest. Den Zustand im Inneren (added, soldOut), die Namen von Funktionen und Klassen. Sie ändern sich bei einem Refactoring. Du testest die Eingabe (Props, Klicks, die Antworten der API) und die Ausgabe (die Seite und die Aufrufe nach oben).
Die Sandbox. Hier sind vitest und Testing Library kleine Versionen mit denselben Namen: nur der Teil der API, den diese Lektion benutzt. findBy und waitFor warten bis zu 1 Sekunde, und ein Test wird nach 4 Sekunden abgebrochen. Der Testcode ist geschrieben wie in einem echten Projekt.
Übung
Tests für den Warenkorb
CartSummary zeigt die Zeilen („Weißbrot × 2“ mit einem Button „Entfernen“), die Summe („Gesamt: 4,00 €“) und „Der Warenkorb ist leer“, wenn er leer ist. Bei „Entfernen“ sagt es dem Elternelement, welche Zeile zu entfernen ist. Schreib Tests, die drei defekte Versionen finden: Die Summe ignoriert die Menge, „Entfernen“ schickt die erste Zeile statt der eigenen, und der leere Warenkorb sagt nichts. Die Prüfung verlangt, dass alle deine Tests beim richtigen Code bestehen und alle drei Fehler gefunden werden.
Konsole
Lösung
CartSummary.test.jsximport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CartSummary from './CartSummary.jsx';
const bread = { id: 1, name: 'Weißbrot', price: 1.2, qty: 2 };
const milk = { id: 5, name: 'Frischmilch', price: 1.6, qty: 1 };
test('zeigt die Zeilen', () => {
render(<CartSummary items={[bread, milk]} onRemove={vi.fn()} />);
expect(screen.getByText('Weißbrot × 2')).toBeInTheDocument();
expect(screen.getByText('Frischmilch × 1')).toBeInTheDocument();
});
test('die Summe zählt die Menge', () => {
render(<CartSummary items={[bread, milk]} onRemove={vi.fn()} />);
expect(screen.getByText('Gesamt: 4,00 €')).toBeInTheDocument();
});
test('„Entfernen“ schickt seine eigene Zeile', async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
render(<CartSummary items={[bread, milk]} onRemove={onRemove} />);
await user.click(screen.getByRole('button', { name: 'Entfernen Frischmilch' }));
expect(onRemove).toHaveBeenCalledWith(milk);
});
test('der leere Warenkorb sagt es', () => {
render(<CartSummary items={[]} onRemove={vi.fn()} />);
expect(screen.getByText('Der Warenkorb ist leer')).toBeInTheDocument();
});
CartSummary.jsximport { money } from './money.js';
export default function CartSummary({ items, onRemove }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (items.length === 0) return <p>Der Warenkorb ist leer</p>;
return (
<section aria-label="Der Warenkorb">
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Entfernen ${item.name}`} onClick={() => onRemove(item)}>
Entfernen
</button>
</li>
))}
</ul>
<p>Gesamt: {money(total)}</p>
</section>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
Drei Tests, einer für jedes Verhalten:
- Die Summe: eine Zeile mit Menge 2 (
bread) und eine mit 1 (milk) ergeben „Gesamt: 4,00 €“. Hat jede Zeile die Menge 1, sind Preis und Preis × Menge gleich, und der Fehler zeigt sich nicht. - „Entfernen“: Drück den Button der zweiten Zeile („Entfernen Frischmilch“) und prüfe
toHaveBeenCalledWith(milk). Drückst du die erste Zeile, schickt die defekte Version auch die erste Zeile, und der Test besteht. - Der leere Warenkorb:
items: []und „Der Warenkorb ist leer“ auf der Seite.
Ein guter Test wählt Daten, bei denen sich der Fehler zeigt. Es ist dasselbe Können wie bei Tests von Funktionen: die Fälle am Rand.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Zustand · Gemeinsamer Zustand: nach oben · Effekte · Daten von einer API in einer Komponente
- Geht weiter in: Von der Sandbox zum Projekt · Das Schaufenster des Shops
- Aufgaben zum Thema: Eine ganze App