MSW: Mock Service Worker für API-Tests in React ohne Backend
AI generated
</>
{ }
React · Testing · MSW · API Mocking
MSW: Mock Service Worker für API-Tests
ohne Backend, ohne fragile fetch-Mocks

Wer React-Komponenten testet, die Daten laden, steht vor derselben Frage: fetch mocken oder gegen ein echtes Backend testen. MSW (Mock Service Worker) fängt Netzwerk-Requests auf Protokollebene ab, sodass Komponenten genau den Code ausführen, den sie auch in Produktion nutzen, während die Antworten vollständig kontrolliert bleiben.

17 Min. Lesezeit MSW 2 · Vitest · Testing Library REST & GraphQL

1. Warum MSW und nicht einfach fetch mocken

Klassisches Mocking ersetzt global.fetch oder das axios-Modul durch eine Jest- oder Vitest-Mock-Funktion, die einen fest verdrahteten Wert zurückgibt. Das Problem: Die Komponente ruft ihre Datenzugriffsschicht dabei nie wirklich auf. Wenn sich die Struktur von fetch(url, options) ändert, ein zusätzlicher Header hinzukommt oder von axios auf die native Fetch-API gewechselt wird, bleiben alle Modul-Mocks grün, obwohl der eigentliche Code längst kaputt ist. MSW löst dieses Problem, indem es nicht das Modul, sondern die Netzwerkschicht selbst abfängt, und zwar mit denselben Request-Handlern in Node-Tests und im echten Browser.

Der entscheidende Architekturunterschied: MSW registriert einen Interceptor auf Ebene von http.request in Node beziehungsweise einen echten Service Worker im Browser. Die getestete Komponente führt also ihren kompletten, unveränderten Data-Fetching-Code aus, inklusive Retry-Logik, Header-Konstruktion und Response-Parsing. Nur die Antwort auf der Leitung wird von MSW ersetzt. Das macht Tests aussagekräftiger, weil ein Fehler in der eigentlichen Fetch-Implementierung nicht mehr durch das Mocking verdeckt wird.

Ein weiterer Vorteil von MSW ist die Wiederverwendbarkeit der Handler. Dieselben Request-Handler, die in Unit- und Integrationstests laufen, lassen sich unverändert für lokale Entwicklung ohne Backend, für Storybook-Stories und teilweise sogar für Demo-Umgebungen einsetzen. Das reduziert Duplikation zwischen Testfixtures und Mock-Daten, die sonst in mehreren Ecken des Projekts getrennt gepflegt werden müssten.

2. Setup: Handler, Server und Test-Integration

Der Einstieg in MSW folgt einem festen Muster: Handler definieren, einen Server für Node-Tests aufsetzen und ihn im Test-Lifecycle registrieren. Die Installation erfolgt über npm install msw --save-dev, danach werden Handler in einer eigenen Datei zentral gesammelt. Für Vitest oder Jest wird zusätzlich setupServer aus msw/node importiert, das den Interceptor für die gesamte Testsuite verwaltet.

Wichtig ist der Lifecycle: server.listen() vor allen Tests, server.resetHandlers() nach jedem einzelnen Test und server.close() nach der gesamten Suite. Ohne resetHandlers nach jedem Test können Handler-Overrides aus einem Test in den nächsten durchsickern, was zu schwer nachvollziehbaren, testreihenfolge-abhängigen Fehlern führt. Die Option onUnhandledRequest: "error" ist in der Praxis Pflicht, weil sie sofort sichtbar macht, wenn eine Komponente einen Request an einen Endpunkt schickt, für den kein Handler existiert.


// mocks/handlers.ts — central request handler registry
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params
    return HttpResponse.json({ id, name: 'Ada Lovelace', role: 'admin' })
  }),
]

// mocks/server.ts — Node server for test environment
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

// vitest.setup.ts — lifecycle wiring
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

3. Request-Handler für REST-Endpunkte schreiben

Ein Request-Handler in MSW ist eine Funktion, die HTTP-Methode und Pfad an einen Resolver bindet. Der Resolver erhält Zugriff auf Pfadparameter, Query-Parameter, Request-Body und Header, und gibt eine HttpResponse zurück. Diese Symmetrie zu echten Server-Handlern ist bewusst gewählt, damit Entwickler mit Express- oder Fastify-Erfahrung sofort produktiv sind. Für verschachtelte Ressourcen wie /api/orders/:orderId/items unterstützt MSW dieselbe Path-to-RegExp-Syntax wie gängige Router.

Ein häufiges Muster ist das Mocken einer paginierten Liste, bei der Query-Parameter wie page und limit ausgewertet werden, um einen realistischen Ausschnitt der Daten zurückzugeben. Das ist wichtig, weil Komponenten, die Paginierung testen, sonst mit statischen Fixtures nie prüfen können, ob die Seiten-Logik tatsächlich funktioniert. MSW erlaubt dabei vollen Zugriff auf request.url.searchParams, sodass der Handler wie ein Mini-Backend reagiert.


// mocks/handlers.ts — paginated list endpoint with query params
import { http, HttpResponse } from 'msw'

const allProducts = Array.from({ length: 47 }, (_, i) => ({
  id: i + 1,
  name: `Product ${i + 1}`,
  price: 9.99 + i,
}))

export const handlers = [
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url)
    const page = Number(url.searchParams.get('page') ?? '1')
    const limit = Number(url.searchParams.get('limit') ?? '10')
    const start = (page - 1) * limit

    return HttpResponse.json({
      items: allProducts.slice(start, start + limit),
      total: allProducts.length,
      page,
    })
  }),

  http.post('/api/products', async ({ request }) => {
    const body = await request.json() as { name: string; price: number }
    return HttpResponse.json({ id: 48, ...body }, { status: 201 })
  }),
]

4. Fehlerzustände, Latenz und Netzwerkabbrüche simulieren

Der größte praktische Nutzen von MSW zeigt sich beim Testen von Fehlerpfaden. Eine Komponente, die einen Ladeindikator, eine Fehlermeldung und einen Retry-Button anzeigt, braucht Tests für alle drei Zustände, nicht nur für den Erfolgsfall. Mit MSW lässt sich ein Handler pro Test gezielt auf einen 500er-Status, einen 401 mit anschließendem Redirect-Flow oder eine komplett fehlgeschlagene Verbindung umstellen, ohne die Produktionslogik anzufassen.

Für realistische Ladezustände unterstützt MSW künstliche Verzögerung über delay() aus dem Kernpaket. Das ist besonders wertvoll, um Race Conditions aufzudecken, etwa wenn ein Nutzer schnell zwischen zwei Detailseiten wechselt und die Antwort der ersten Anfrage später eintrifft als die der zweiten. Ohne eine kontrollierte Verzögerung bleiben solche Bugs in Tests, die immer synchron auflösen, komplett unsichtbar.


// user-profile.test.tsx — testing error and slow-network states
import { http, HttpResponse, delay } from 'msw'
import { render, screen, waitFor } from '@testing-library/react'
import { server } from '../mocks/server'
import { UserProfile } from './UserProfile'

test('shows error message on 500 response', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json({ message: 'Internal error' }, { status: 500 })
    })
  )

  render(<UserProfile userId="42" />)

  await waitFor(() => {
    expect(screen.getByText(/etwas ist schiefgelaufen/i)).toBeInTheDocument()
  })
})

test('shows loading spinner while request is pending', async () => {
  server.use(
    http.get('/api/users/:id', async () => {
      await delay(200)
      return HttpResponse.json({ id: '42', name: 'Ada Lovelace' })
    })
  )

  render(<UserProfile userId="42" />)
  expect(screen.getByRole('status')).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
  })
})

test('handles network failure gracefully', async () => {
  server.use(http.get('/api/users/:id', () => HttpResponse.error()))
  render(<UserProfile userId="42" />)

  await waitFor(() => {
    expect(screen.getByText(/verbindung fehlgeschlagen/i)).toBeInTheDocument()
  })
})

5. GraphQL-Operationen mit MSW mocken

Neben REST unterstützt MSW auch GraphQL nativ über eine eigene Handler-API, die auf Operationsnamen statt URLs matcht. Das ist entscheidend, weil GraphQL-Requests fast immer denselben Endpunkt /graphql ansprechen und sich nur im Query- oder Mutation-Namen unterscheiden. Der Handler graphql.query('GetUser', resolver) matcht jede Anfrage mit diesem Operationsnamen, unabhängig von zusätzlichen Feldern in der Query.

Für Projekte, die Apollo Client oder urql einsetzen, ist dieser Ansatz deutlich robuster als das Mocken des GraphQL-Clients selbst, weil Caching-Verhalten, Fehlerlinks und Retry-Mechanismen des Clients weiterhin real ausgeführt werden. Variablen aus der Mutation lassen sich über variables im Resolver auslesen, sodass auch parametrisierte Mutationen wie das Anlegen eines Kommentars realistisch beantwortet werden können.


// mocks/handlers.ts — GraphQL query and mutation handlers
import { graphql, HttpResponse } from 'msw'

export const handlers = [
  graphql.query('GetUser', ({ variables }) => {
    return HttpResponse.json({
      data: { user: { id: variables.id, name: 'Ada Lovelace' } },
    })
  }),

  graphql.mutation('CreateComment', ({ variables }) => {
    return HttpResponse.json({
      data: {
        createComment: { id: 'c-1', text: variables.text, createdAt: new Date().toISOString() },
      },
    })
  }),

  graphql.query('GetUser', ({ variables }) => {
    if (variables.id === 'missing') {
      return HttpResponse.json({
        errors: [{ message: 'User not found' }],
      })
    }
  }),
]

6. Handler pro Test überschreiben statt duplizieren

Ein häufiger Anfängerfehler bei MSW ist, für jeden einzelnen Testfall eine komplett neue Handler-Datei zu erstellen. Stattdessen sollte die Basis-Handler-Liste die typischen Erfolgsfälle abdecken, während einzelne Tests mit server.use(...) gezielt einen Handler für die Dauer dieses einen Tests überschreiben. Da resetHandlers() nach jedem Test aufgerufen wird, fällt der Server danach automatisch auf die Basis-Handler zurück.

Dieses Muster hält die Handler-Definitionen DRY und macht sofort ersichtlich, welcher Test von der Norm abweicht. Für komplexere Szenarien, in denen mehrere aufeinanderfolgende Requests unterschiedliche Antworten liefern müssen, zum Beispiel ein erster fehlschlagender Versuch gefolgt von einem erfolgreichen Retry, unterstützt MSW das Verketten mehrerer http.get-Aufrufe mit { once: true }, sodass jeder Aufruf nur einmal greift, bevor der nächste registrierte Handler übernimmt.


// retry-logic.test.tsx — first call fails, second call succeeds
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'

test('retries once after a failed request', async () => {
  let callCount = 0

  server.use(
    http.get('/api/orders', () => {
      callCount += 1
      if (callCount === 1) {
        return HttpResponse.json({ message: 'timeout' }, { status: 504 })
      }
      return HttpResponse.json({ items: [] })
    })
  )

  // Component under test performs its own retry on 504
  render(<OrdersList />)
  await waitFor(() => expect(callCount).toBe(2))
})

7. MSW im Browser: Service Worker für lokale Entwicklung

Über mocks/browser.ts und setupWorker aus msw/browser lässt sich dieselbe Handler-Liste auch im echten Browser registrieren. Das ist besonders für Frontend-Teams wertvoll, die parallel zum Backend entwickeln, dessen Endpunkte noch nicht fertig implementiert sind. Der Service Worker wird per npx msw init public/ als statische Datei ins Projekt kopiert und muss vom Entwicklungsserver ausgeliefert werden.

Ein praktischer Vorteil dieses Setups: Neue Features lassen sich unabhängig vom Backend-Fortschritt bauen und in Storybook demonstrieren, ohne dass ein echtes API-Team blockierend im kritischen Pfad liegt. Sobald das echte Backend verfügbar ist, wird der Worker einfach deaktiviert, ohne dass Komponenten-Code angepasst werden muss, weil dieselbe Fetch-Schicht unverändert bleibt.

8. Typische Fallstricke bei der MSW-Einführung

Der häufigste Fehler bei MSW ist ein fehlendes await vor asynchronen Assertions. Da MSW Requests über echte Promise-Ketten auflöst, muss jede Erwartung, die auf eine Netzwerkantwort wartet, mit waitFor oder findBy*-Queries aus Testing Library formuliert werden. Ein synchrones getByText direkt nach render() läuft in eine Race Condition, weil die Antwort des Mock-Servers noch nicht angekommen ist.

Ein zweiter Fallstrick betrifft absolute versus relative URLs. Wenn die Anwendung fetch("https://api.example.com/users") mit vollständiger Domain aufruft, der Handler aber nur auf /users registriert ist, greift der Mock nicht. Die Lösung ist, entweder die Basis-URL in den Handlern zu spiegeln oder in Tests eine relative Basis-URL über einen Environment-Mock zu erzwingen. Ein dritter Fallstrick ist das Vergessen von onUnhandledRequest: "error", wodurch nicht gemockte Requests unbemerkt echte Netzwerkaufrufe auslösen und Tests in CI-Umgebungen ohne Internetzugang unvorhersehbar fehlschlagen.

9. MSW im Vergleich zu anderen Mocking-Strategien

Die Wahl der richtigen Mocking-Strategie hängt vom Testziel ab. MSW ist die richtige Wahl, wenn die tatsächliche Fetch-Implementierung mitgetestet werden soll, während Modul-Mocks für sehr isolierte Unit-Tests einzelner Funktionen weiterhin sinnvoll bleiben können.

Strategie Was wird ersetzt Vorteil Nachteil
jest.mock('axios') Das gesamte HTTP-Modul Sehr schnell, keine Netzwerkschicht Testet nicht den echten Request-Aufbau
MSW (Node) Netzwerk-Ebene, nicht das Modul Realistisch, Client-Code bleibt unangetastet Etwas mehr Setup-Aufwand
Echtes Test-Backend Nichts, vollständig real Maximale Realitätsnähe Langsam, instabil, schwer zu isolieren
MSW (Browser, Dev) Service Worker fängt echte Requests ab Frontend-Entwicklung ohne Backend-Blockade Worker-Setup nötig, nicht für Produktion

In der Praxis kombinieren erfolgreiche Teams beide Ansätze: MSW für alle Tests, die Datenfluss und UI-Zustände abdecken, sowie klassische Unit-Tests ohne jedes Netzwerk für reine Utility-Funktionen. Diese Kombination hält die Testsuite schnell, während die kritischen Integrationspfade weiterhin realistisch abgedeckt bleiben.

10. Zusammenfassung

MSW verschiebt API-Mocking von der Modulebene auf die Netzwerkebene und lässt React-Komponenten dabei ihren vollständigen, unveränderten Data-Fetching-Code ausführen. Request-Handler für REST und GraphQL werden zentral definiert, per server.use() pro Test überschrieben und decken so Erfolgsfälle, Fehlerzustände, Latenz und Race Conditions realistisch ab. Dieselben Handler funktionieren identisch in Node-Tests und im Browser über einen echten Service Worker.

Der größte Gewinn gegenüber klassischen fetch-Mocks liegt in der Testtiefe: Ein Bug in der Request-Konstruktion, in Retry-Logik oder Header-Handling wird mit MSW sichtbar, während er bei Modul-Mocks unentdeckt bliebe. Teams, die MSW konsequent einsetzen, berichten von deutlich weniger produktionsspezifischen API-Bugs, weil die Tests dieselbe Schicht prüfen, die auch in Produktion läuft.

MSW für API-Tests in React — Das Wichtigste auf einen Blick

Netzwerk statt Modul

MSW fängt Requests auf HTTP-Ebene ab, der echte Fetch-Code der Komponente bleibt unverändert und wird mitgetestet.

Fehlerzustände realistisch testen

500er, 401, Netzwerkabbruch und Latenz mit delay() lassen sich pro Test gezielt simulieren, ohne Produktionscode zu ändern.

REST und GraphQL

Einheitliche Handler-API für beide Protokolle, GraphQL matcht über Operationsnamen statt über die URL.

Node und Browser identisch

Dieselben Handler laufen in der Testsuite und als Service Worker für lokale Entwicklung ohne Backend.

11. FAQ: MSW für API-Tests in React

1MSW vs. jest.mock('axios')?
jest.mock ersetzt das ganze Modul, der echte Request-Code läuft nie. MSW fängt auf Netzwerkebene ab, Header und URL-Aufbau bleiben real getestet.
2Funktioniert MSW mit GraphQL?
Ja, eigene graphql-Handler-API, die über Operationsnamen matcht statt über die URL.
3Serverfehler nur für einen Test simulieren?
server.use() innerhalb des Tests, danach setzt resetHandlers() automatisch zurück.
4Handler greift trotz korrektem Pfad nicht?
Meist stimmt die Basis-URL nicht, absolute vs. relative URL prüfen.
5MSW auch im Browser nötig?
Nicht zwingend, aber wertvoll für Entwicklung ohne fertiges Backend über einen Service Worker.
6Race Conditions mit MSW testen?
delay() aus dem Kernpaket baut kontrollierte Latenz ein, macht Race-Condition-Bugs sichtbar.
7Was macht onUnhandledRequest: 'error'?
Test schlägt sofort fehl, wenn ein Request ohne registrierten Handler auftaucht, statt unbemerkt echt auszuführen.
8Datei-Uploads testbar?
Ja, über request.formData() im Resolver auslesbar und in der Mock-Antwort berücksichtigt.
9Handler zentral oder pro Datei?
Zentral für Erfolgsfälle, gezielte Overrides via server.use() pro Test, das hält alles DRY.
10Ersetzt MSW echte E2E-Tests?
Nein, MSW deckt Frontend-Logik ab und ergänzt E2E-Tests gegen ein echtes Backend sinnvoll.