renderHook in der Praxis
Ein Custom Hook wird zu oft nur indirekt über die Komponente getestet, die ihn verwendet. Das verdeckt Bugs in der Hook-Logik und macht Tests unnötig komplex. renderHook aus der React Testing Library erlaubt es, Zustand, Nebenwirkungen und asynchrone Updates eines Hooks direkt zu prüfen, ganz ohne künstliche Host-Komponente.
Inhaltsverzeichnis
- 1. Warum Custom Hooks eigene Tests brauchen
- 2. renderHook: Grundlagen und Rückgabewerte
- 3. Zustand und Updates mit result.current prüfen
- 4. act() und wann es wirklich nötig ist
- 5. Asynchrone Hooks mit waitFor testen
- 6. Wrapper-Provider für Hooks mit Context-Abhängigkeit
- 7. rerender() für Props-Änderungen und Cleanup-Effekte
- 8. Typische Fallstricke bei Hook-Tests
- 9. renderHook im Vergleich zum Test über die Host-Komponente
- 10. Zusammenfassung
- 11. FAQ
1. Warum Custom Hooks eigene Tests brauchen
Ein Custom Hook kapselt oft komplexe Logik: Debouncing, Formularvalidierung, Paginierung oder das Verwalten eines WebSocket-Zustands. Wird ein solcher Hook nur indirekt über eine Komponente getestet, die ihn verwendet, vermischen sich zwei völlig unterschiedliche Verantwortungen in einem einzigen Test: das korrekte Verhalten des Hooks und das korrekte Rendering der Komponente. Schlägt der Test fehl, ist zunächst unklar, welche der beiden Ebenen die Ursache ist.
Direktes Testen von Custom Hooks löst dieses Problem, indem der Hook isoliert von jeder konkreten UI ausgeführt wird. renderHook aus @testing-library/react erstellt dafür intern eine minimale Host-Komponente, die für den Test unsichtbar bleibt, und führt den Hook darin aus. Der Rückgabewert des Hooks wird über result.current zugänglich gemacht, sodass Assertions direkt auf den Werten und Funktionen des Hooks formuliert werden können, ohne dass eine sichtbare Komponente gerendert werden muss.
Der praktische Nutzen zeigt sich vor allem bei wiederverwendbaren Custom Hooks, die in mehreren Komponenten eingesetzt werden. Ein isolierter Test für useDebounce oder usePagination deckt die Logik einmal ab, statt sie in jeder verwendenden Komponente erneut indirekt mitzutesten. Das reduziert Testduplikation erheblich und macht Fehlerursachen sofort lokalisierbar.
2. renderHook: Grundlagen und Rückgabewerte
renderHook(callback, options) nimmt eine Funktion entgegen, die den zu testenden Hook aufruft, und gibt ein Objekt mit result, rerender und unmount zurück. result.current enthält immer den aktuellsten Rückgabewert des Hooks nach dem letzten Render. Diese Struktur bildet exakt nach, wie ein Hook in einer echten Komponente verwendet würde, nur ohne jegliches sichtbares Markup drumherum.
Ein wichtiges Detail: Der Custom Hook wird bei jedem Aufruf von rerender() erneut ausgeführt, genau wie eine Komponente bei jedem Render-Zyklus. Das ermöglicht, Props-Änderungen zu simulieren, indem rerender mit neuen Argumenten aufgerufen wird, die dann im nächsten Aufruf des Hook-Callbacks verwendet werden.
// useCounter.ts — the custom hook under test
import { useState, useCallback } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((c) => c + 1), [])
const decrement = useCallback(() => setCount((c) => c - 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, decrement, reset }
}
// useCounter.test.ts — isolated test with renderHook
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
test('increments and decrements the counter', () => {
const { result } = renderHook(() => useCounter(5))
expect(result.current.count).toBe(5)
act(() => result.current.increment())
expect(result.current.count).toBe(6)
act(() => result.current.decrement())
expect(result.current.count).toBe(5)
})
3. Zustand und Updates mit result.current prüfen
Der zentrale Zugriffspunkt bei jedem Test mit renderHook ist result.current. Nach jedem State-Update muss dieser Wert erneut ausgelesen werden, weil result.current zwar automatisch aktualisiert wird, eine bereits destrukturierte Variable aber den alten Wert behält. Ein häufiger Anfängerfehler ist, const { count } = result.current einmal am Testanfang zu destrukturieren und danach mit der veralteten Kopie weiterzuarbeiten, statt jedes Mal erneut über result.current zuzugreifen.
Für komplexere Custom Hooks, die mehrere zusammenhängende Zustandswerte verwalten, etwa einen Formular-Hook mit values, errors und isSubmitting, lohnt es sich, in jedem Test gezielt nur den relevanten Ausschnitt von result.current zu prüfen, statt den gesamten Rückgabewert in einer einzigen, unübersichtlichen Assertion zusammenzufassen.
// useToggle.test.ts — checking result.current after each state change
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'
test('toggles boolean state on each call', () => {
const { result } = renderHook(() => useToggle(false))
expect(result.current.value).toBe(false)
act(() => result.current.toggle())
// Must re-read result.current — the old destructured value is stale
expect(result.current.value).toBe(true)
act(() => result.current.toggle())
expect(result.current.value).toBe(false)
})
4. act() und wann es wirklich nötig ist
act() stellt sicher, dass alle React-State-Updates innerhalb des Callbacks vollständig verarbeitet werden, bevor die nächste Assertion ausgeführt wird. Ohne act() um einen State-Update-Aufruf kann React eine Warnung im Testlog ausgeben, weil das Update außerhalb des kontrollierten Render-Zyklus stattfindet. Der Grundsatz: Jede Interaktion mit dem Custom Hook, die intern setState auslöst, gehört in act().
Neuere Versionen von renderHook und Testing Library wickeln viele Fälle bereits automatisch in act(), insbesondere den initialen Render und rerender()-Aufrufe. Manuelles act() bleibt aber notwendig, wenn eine vom Hook zurückgegebene Funktion außerhalb dieser automatischen Wrapper aufgerufen wird, zum Beispiel bei einem direkten Aufruf von result.current.increment() in einer Test-Assertion.
5. Asynchrone Hooks mit waitFor testen
Viele Custom Hooks kapseln asynchrone Logik, etwa das Laden von Daten in einem useEffect oder eine debounced Suche. Für diese Fälle reicht act() allein nicht aus, weil das Update erst nach Abschluss einer Promise-Kette eintrifft. Die Testing Library stellt dafür waitFor bereit, das wiederholt prüft, bis eine Bedingung erfüllt ist, ohne feste Timeouts im Test hart zu kodieren.
Ein häufiges Muster: Ein Custom Hook wie useFetch setzt zunächst isLoading: true, führt dann den Request aus und aktualisiert den Zustand asynchron. Der Test prüft zuerst den initialen Ladezustand synchron, wartet dann mit waitFor auf den finalen Zustand, statt anzunehmen, dass das Update sofort verfügbar ist.
// useFetch.test.ts — testing an async custom hook
import { renderHook, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'
test('loads data asynchronously and updates state', async () => {
const { result } = renderHook(() => useFetch('/api/users/1'))
// Synchronous initial state, no waiting needed
expect(result.current.isLoading).toBe(true)
expect(result.current.data).toBeNull()
// Wait until the async effect resolves and updates state
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual({ id: '1', name: 'Ada Lovelace' })
expect(result.current.error).toBeNull()
})
6. Wrapper-Provider für Hooks mit Context-Abhängigkeit
Viele Custom Hooks greifen über useContext auf einen Provider zu, etwa für Theming, Authentifizierung oder Feature-Flags. renderHook akzeptiert dafür die Option wrapper, eine Komponente, die den Hook beim Rendern umschließt und den benötigten Context bereitstellt. Ohne diesen Wrapper würde der Hook mit dem Default-Wert des Context arbeiten oder einen Fehler werfen, wenn der Context obligatorisch ist.
Dieses Muster erlaubt es, denselben Custom Hook mit unterschiedlichen Context-Werten zu testen, etwa einen eingeloggten und einen ausgeloggten Zustand, ohne die eigentliche Hook-Implementierung zu verändern. Der Wrapper wird pro Test oder pro describe-Block neu zusammengesetzt, je nachdem, welcher Zustand geprüft werden soll.
// useCurrentUser.test.ts — providing context via the wrapper option
import { renderHook } from '@testing-library/react'
import { AuthContext } from '../AuthContext'
import { useCurrentUser } from './useCurrentUser'
function createWrapper(user: { id: string; name: string } | null) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<AuthContext.Provider value={{ user, isAuthenticated: user !== null }}>
{children}
</AuthContext.Provider>
)
}
}
test('returns the authenticated user from context', () => {
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper({ id: '1', name: 'Ada Lovelace' }),
})
expect(result.current.isAuthenticated).toBe(true)
expect(result.current.user?.name).toBe('Ada Lovelace')
})
test('returns null when no user is authenticated', () => {
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(null),
})
expect(result.current.isAuthenticated).toBe(false)
})
7. rerender() für Props-Änderungen und Cleanup-Effekte
Manche Custom Hooks reagieren auf Änderungen ihrer Argumente, etwa ein useDebounce(value, delay), der bei jeder Änderung von value einen neuen Timer startet. rerender(newArgs) simuliert genau dieses Verhalten, indem der Hook-Callback mit den neuen Argumenten erneut ausgeführt wird, ohne dass der komplette Test neu aufgesetzt werden muss.
Ebenso wichtig ist das Testen von Cleanup-Effekten über unmount(). Ein Custom Hook, der in einem useEffect ein Intervall startet oder einen Event-Listener registriert, sollte diesen beim Unmount wieder entfernen. Der Test ruft unmount() auf und prüft anschließend, ob die entsprechende Cleanup-Funktion, zum Beispiel clearInterval oder removeEventListener, tatsächlich aufgerufen wurde.
// useInterval.test.ts — rerender for changed deps, unmount for cleanup
import { renderHook } from '@testing-library/react'
import { useInterval } from './useInterval'
test('restarts the interval when delay changes', () => {
const callback = vi.fn()
const { rerender } = renderHook(
({ delay }) => useInterval(callback, delay),
{ initialProps: { delay: 1000 } }
)
rerender({ delay: 500 })
// Assertions on timer behavior would follow with vi.useFakeTimers()
})
test('clears the interval on unmount', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval')
const { unmount } = renderHook(() => useInterval(() => {}, 1000))
unmount()
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
})
8. Typische Fallstricke bei Hook-Tests
Der häufigste Fehler bei Tests mit renderHook ist das Vergessen von act() um synchrone State-Updates, gefolgt von der Verwendung von veralteten, bereits destrukturierten Werten statt eines erneuten Zugriffs auf result.current. Ein weiterer verbreiteter Fehler betrifft Custom Hooks, die auf Refs statt auf State setzen, um teure Berechnungen zu memoizen: Ein Test, der nur result.current nach einem Render prüft, sieht möglicherweise nicht, dass sich die Ref korrekt aktualisiert hat, weil Ref-Änderungen keinen Re-Render auslösen.
Ein dritter Fallstrick betrifft Timer-basierte Custom Hooks. Ohne vi.useFakeTimers() muss ein Test auf einen echten Debounce oder ein echtes Intervall warten, was Testläufe unnötig verlangsamt und in CI-Umgebungen zu Flakiness führt. Fake Timer in Kombination mit act(() => vi.advanceTimersByTime(500)) lösen dieses Problem, indem die Zeit im Test kontrolliert vorgespult wird, statt real zu vergehen.
9. renderHook im Vergleich zum Test über die Host-Komponente
Beide Ansätze haben ihre Berechtigung, decken aber unterschiedliche Aspekte ab. Die folgende Tabelle zeigt, wann welcher Ansatz vorzuziehen ist.
| Kriterium | renderHook (isoliert) | Test über Host-Komponente |
|---|---|---|
| Fehlerlokalisierung | Direkt auf Hook-Logik eingrenzbar | Unklar: Hook oder Komponente? |
| Wiederverwendbare Hooks | Ein Test für alle Verwendungsstellen | Duplizierte Tests pro Komponente |
| UI-Interaktion testen | Nicht möglich, kein Markup | Genau dafür gedacht |
| Setup-Aufwand | Gering, nur wrapper bei Context-Bedarf | Höher, komplette Komponente rendern |
| Geeignet für | Zustandslogik, Nebenwirkungen, Daten-Hooks | Nutzerflüsse, sichtbares Verhalten |
In der Praxis ergänzen sich beide Ebenen. Ein isolierter Test mit renderHook deckt die Logik des Custom Hooks vollständig ab, während ein zusätzlicher, schlanker Test über die Host-Komponente sicherstellt, dass die Integration mit der UI funktioniert, ohne dass dieser zweite Test jede einzelne Verzweigung des Hooks erneut durchtesten muss.
10. Zusammenfassung
Custom Hooks isoliert zu testen, trennt die Verantwortung der Hook-Logik von der Verantwortung des UI-Renderings und macht Fehlerursachen sofort erkennbar. renderHook stellt über result.current Zugriff auf den aktuellen Rückgabewert bereit, act() synchronisiert State-Updates, waitFor deckt asynchrone Logik ab, und die wrapper-Option ermöglicht Tests für Hooks mit Context-Abhängigkeit.
Der größte Gewinn liegt in der Wiederverwendbarkeit: Ein sauber getesteter Custom Hook muss nicht in jeder Komponente, die ihn nutzt, erneut indirekt mitgetestet werden. Das reduziert Testduplikation, beschleunigt die Testsuite und macht die Ursache eines Fehlschlags sofort ersichtlich, statt zwischen Hook-Logik und Komponenten-Rendering raten zu müssen.
Custom Hooks isoliert testen — Das Wichtigste auf einen Blick
renderHook statt Host-Komponente
result.current gibt direkten Zugriff auf Zustand und Funktionen des Hooks ohne sichtbares Markup.
act() für synchrone Updates
Jede Interaktion, die intern setState auslöst, gehört in act(), sonst folgen React-Warnungen.
waitFor für Asynchronität
Asynchrone Hooks brauchen waitFor statt fester Timeouts, um auf den finalen Zustand zu warten.
wrapper für Context-Abhängigkeit
Hooks mit useContext brauchen einen Provider-Wrapper, um realistische Context-Werte bereitzustellen.