API Mocking

Mock Service Worker (MSW) Setup, Handler-Architektur und Patterns für das Mocken von API-Responses in Tests und der Entwicklung

Das Kit verwendet Mock Service Worker (MSW), um HTTP-Anfragen auf Netzwerkebene abzufangen und Mock-Responses zurückzugeben. Das bedeutet, dass deine Tests und die lokale Entwicklungsumgebung keine Datenbank, keine externen APIs und keine Service-Accounts benötigen — MSW ersetzt alles durch deterministische, sofortige Responses.
Die Mock-Schicht umfasst 12 Handler-Dateien, die jede API-Oberfläche abdecken: AI-Chat mit Streaming (inkl. Vision/Bildanalyse), Bildgenerierung, Payment-Subscriptions, Credit-System, Rate-Limiting, Datei-Uploads, E-Mail und mehr.

Architektur

MSW läuft in zwei Modi, abhängig von der Umgebung:
ModusModulVerwendet vonAbfangmethode
Serverapps/boilerplate/src/mocks/server.tsVitest (Node.js)Patcht http/https-Module
Browserapps/boilerplate/src/mocks/browser.tsE2E-Tests, Dev-ServerService Worker (mockServiceWorker.js)
Beide Modi teilen sich dieselben Handler-Dateien — du schreibst Handler einmal und sie funktionieren überall.

Server-Modus (Vitest)

src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

// This configures a request mocking server with the given request handlers.
// This server will be used in tests to intercept and mock HTTP requests.
export const server = setupServer(...handlers)

// Establish API mocking before all tests.
// This ensures that the mock server is running and ready to intercept requests.
export function startServer() {
  server.listen({
    onUnhandledRequest: 'warn', // Warn about unhandled requests in tests
  })
}

// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
export function resetServer() {
  server.resetHandlers()
}

// Clean up after the tests are finished.
export function stopServer() {
  server.close()
}
Der Server wird in apps/boilerplate/src/test/setup.ts vor allen Tests gestartet und danach geschlossen. Jeder Test setzt Handler zurück, um Zustandsübertragungen zu verhindern.

Browser-Modus (E2E / Entwicklung)

src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

// This configures a Service Worker with the given request handlers.
// This worker will be used in the browser during development.
export const worker = setupWorker(...handlers)

// Start the worker
export async function startWorker() {
  if (typeof window === 'undefined') {
    return
  }

  // Only start the worker in development, test, or demo mode
  // E2E tests need MSW to intercept API calls and return mocked data
  // Demo mode on Vercel runs with NODE_ENV=production but still needs MSW
  if (
    process.env.NODE_ENV !== 'development' &&
    process.env.NODE_ENV !== 'test' &&
    process.env.NEXT_PUBLIC_DEMO_MODE !== 'true'
  ) {
    return
  }

  // Check if MSW is enabled via environment variable
  if (process.env.NEXT_PUBLIC_MSW_ENABLED !== 'true') {
    console.info('MSW is disabled. Set NEXT_PUBLIC_MSW_ENABLED=true to enable.')
    return
  }

  try {
    await worker.start({
      onUnhandledRequest: 'bypass', // Let unhandled requests pass through
      serviceWorker: {
        url: '/mockServiceWorker.js',
      },
    })
    console.info('🔶 MSW (Mock Service Worker) started successfully')
  } catch (error) {
    console.error('Failed to start MSW:', error)
  }
}

// Stop the worker
export function stopWorker() {
  if (typeof window !== 'undefined' && worker) {
    worker.stop()
  }
}
Der Browser-Modus wird aktiviert, wenn:
  1. Die Umgebung development oder test ist
  2. NEXT_PUBLIC_MSW_ENABLED auf true gesetzt ist
In der Entwicklung ermöglicht MSW das Bauen von Features gegen realistische API-Responses, ohne Backend-Services laufen zu lassen. In E2E-Tests liefert er deterministische Daten für Playwright-Assertions.

MSW Utility-Exporte

src/mocks/index.ts
// Re-export all mock utilities for convenient imports
export { handlers } from './handlers'
export { server, startServer, resetServer, stopServer } from './server'
export { worker, startWorker, stopWorker } from './browser'

// Helper to check if MSW is enabled
export const isMSWEnabled = () => {
  if (typeof window === 'undefined') {
    // Server-side: MSW is always enabled in tests
    return process.env.NODE_ENV === 'test'
  }
  // Client-side: Check environment variable
  return process.env.NEXT_PUBLIC_MSW_ENABLED === 'true'
}
Der isMSWEnabled()-Helper erkennt die aktuelle Umgebung: Serverseitig prüft er NODE_ENV, clientseitig das Flag NEXT_PUBLIC_MSW_ENABLED.

Handler-Inventar

Alle Handler werden in apps/boilerplate/src/mocks/handlers.ts zusammengeführt und in ein einziges Array gestreut:
src/mocks/handlers.ts — Handler-Imports
import { http, HttpResponse } from 'msw'
import { isValidRequestBody } from '@/lib/email/types'
import {
  lemonsqueezyHandlers,
  getTierFromSubscription,
} from './lemonsqueezy-handlers'
import { aiHandlers } from './ai-handlers'
import { dashboardHandlers } from './dashboard-handlers'
import { settingsHandlers } from './settings-handlers'
import { billingHandlers } from './billing-handlers'
import { checkoutHandlers } from './checkout-handlers'
import { demoHandlers } from './demo-handlers'
import { creditHistoryHandlers } from './credit-history-handlers'
import { creditPreferencesHandlers } from './credit-preferences-handlers'
import { imageGenHandlers } from './image-gen-handlers'
src/mocks/handlers.ts — Handler-Aggregation
export const handlers = [
  // Include all Lemon Squeezy handlers
  ...lemonsqueezyHandlers,
  // Include all AI handlers
  ...aiHandlers,
  // Include all Dashboard handlers (NEW)
  ...dashboardHandlers,
  // Include all Settings handlers (NEW)
  ...settingsHandlers,
  // Include all Billing handlers (NEW)
  ...billingHandlers,
  // Include all Checkout handlers (PRP: Direct Checkout)
  ...checkoutHandlers,
  // Include all Demo handlers (Demo Mode: tier-aware mocks)
  ...demoHandlers,
  // Include all Credit History handlers (NESAI-037)
  ...creditHistoryHandlers,
  // Include all Credit Preferences handlers (NESAI-054)
  ...creditPreferencesHandlers,
  // Include all Image Generation handlers (NESAI-062)
  ...imageGenHandlers,

Handler-Dateien

DateiGemockte APIsBesondere Features
handlers.tsBenutzer, Posts, Auth, Dashboard, E-Mail, Dateien, Health, FehlerPaginierung, Datei-Upload-Validierung, verzögerte Responses
ai-handlers.tsAI-Chat, RAG-Bot, Konversationen, Usage-TrackingSSE-Streaming (OpenAI-kompatibel), vision-aware Credit-Abzug (Text: 1,5, Bild: 2,0), Token-Schätzung
lemonsqueezy-handlers.tsSubscriptions, Produkte, Varianten, WebhooksDynamische Tier-Berechnung, Kulanzzeiträume, Trial-Behandlung
dashboard-handlers.tsDashboard-Statistiken, Analytics, Activity-FeedRealistische Diagrammdaten, Zeitreihen-Responses
settings-handlers.tsBenutzereinstellungen, PräferenzenProfilaktualisierungen, Präferenz-Umschalter
billing-handlers.tsAbrechnungsverlauf, Rechnungen, ZahlungsmethodenSubscription-Zustandsverwaltung
checkout-handlers.tsDirekter Checkout, Checkout-SessionsCheckout-URL-Generierung, Varianten-Validierung
credit-history-handlers.tsCredit-TransaktionsverlaufPaginierter Credit-Verlauf, Transaktionstypen
credit-preferences-handlers.tsCredit-PräferenzenBonus-Credit-Auto-Use-Präferenz-Umschalter
image-gen-handlers.tsBildgenerierungGPT Image API-Mock mit Base64-Responses
rate-limit-handlers.tsRate-LimitingRate-Limit-Prüfungs- und Reset-Mocks
demo-handlers.tsAlle Demo-Modus-EndpunkteTier-aware Responses, Credit-Balance-Integration

Handler-Patterns

Einfache GET/POST-Handler

typescript
import { http, HttpResponse } from 'msw'

export const myHandlers = [
  // GET — statische Daten zurückgeben
  http.get('/api/posts', () => {
    return HttpResponse.json({
      posts: [{ id: '1', title: 'Hello World' }],
      total: 1,
    })
  }),

  // POST — Request-Body prüfen und antworten
  http.post('/api/posts', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json(
      { id: 'new-post', ...body },
      { status: 201 }
    )
  }),
]

Request-Inspektion

Auf Header, Query-Parameter und Routen-Parameter zugreifen:
typescript
http.get('/api/posts/:slug', ({ params, request }) => {
  const { slug } = params
  const url = new URL(request.url)
  const page = url.searchParams.get('page') || '1'
  const authHeader = request.headers.get('authorization')

  // Extrahierte Werte nutzen, um Response anzupassen
  return HttpResponse.json({ slug, page })
})

Fehler-Simulation

Bestimmte Status-Codes zurückgeben, um Fehlerbehandlung zu testen:
typescript
// Permanenter Fehler-Endpunkt für Tests
http.get('/api/error/500', () => {
  return new HttpResponse(null, {
    status: 500,
    statusText: 'Internal Server Error',
  })
})

// Bedingter Fehler basierend auf Request-Inhalt
http.post('/api/contact', async ({ request }) => {
  const body = await request.json()

  if (!body?.email) {
    return HttpResponse.json(
      { error: 'Email is required' },
      { status: 400 }
    )
  }

  return HttpResponse.json({ success: true })
})

Streaming-Responses (SSE)

Die AI-Handler demonstrieren Server-Sent Events für Streaming-Chat-Responses:
typescript
http.post('/api/ai/chat', () => {
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    start(controller) {
      // Chunks im OpenAI-kompatiblen Format senden
      const chunks = ['Hello', ' from', ' AI', '!']
      chunks.forEach((chunk, i) => {
        const data = JSON.stringify({
          choices: [{ delta: { content: chunk } }],
        })
        controller.enqueue(
          encoder.encode(`data: ${data}\n\n`)
        )
      })
      controller.enqueue(encoder.encode('data: [DONE]\n\n'))
      controller.close()
    },
  })

  return new HttpResponse(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  })
})

Paginierung

Die Datei handlers.ts demonstriert Paginierung mit Query-Parameter-Extraktion:
typescript
http.get('/api/posts', ({ request }) => {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')
  const limit = parseInt(url.searchParams.get('limit') || '10')

  const startIndex = (page - 1) * limit
  const paginatedPosts = allPosts.slice(startIndex, startIndex + limit)

  return HttpResponse.json({
    posts: paginatedPosts,
    total: allPosts.length,
    page,
    totalPages: Math.ceil(allPosts.length / limit),
  })
})

Tier-abhängige Responses

Die Payment-Handler berechnen Subscription-Tiers dynamisch basierend auf dem Status und behandeln Randfälle wie Kulanzzeiträume und abgelaufene Trials:
typescript
http.get('/api/payments/subscription', () => {
  const subscription = {
    status: 'active',
    variantId: 'var_pro_monthly',
    currentPeriodEnd: new Date(
      Date.now() + 30 * 24 * 60 * 60 * 1000
    ).toISOString(),
  }

  // Dynamische Tier-Berechnung — behandelt active, trial,
  // cancelled, paused, expired, past_due Zustände
  const tier = getTierFromSubscription(subscription)

  return HttpResponse.json({
    subscription,
    tier,
    hasActiveSubscription: subscription.status === 'active',
  })
})

Pro-Test-Handler-Überschreibungen

In Vitest (server.use)

Überschreibe jeden Handler für einen einzelnen Test mit server.use(). Die Überschreibung wird automatisch durch server.resetHandlers() in afterEach bereinigt:
typescript
import { server } from '@/mocks/server'
import { http, HttpResponse } from 'msw'

describe('Dashboard', () => {
  it('shows empty state when no data', async () => {
    // Dashboard-Handler nur für diesen Test überschreiben
    server.use(
      http.get('/api/dashboard', () => {
        return HttpResponse.json({
          stats: { totalPosts: 0, totalViews: 0 },
          recentActivity: [],
        })
      })
    )

    // Test erhält jetzt leere Daten vom überschriebenen Handler
    render(<Dashboard />)
    await expect(screen.getByText(/no data yet/i)).toBeInTheDocument()
  })

  it('shows error state on API failure', async () => {
    server.use(
      http.get('/api/dashboard', () => {
        return HttpResponse.json(
          { error: 'Server error' },
          { status: 500 }
        )
      })
    )

    render(<Dashboard />)
    await expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
  })
})

In Playwright (page.route)

Für E2E-Tests verwende Playwrights page.route(), um Anfragen auf Browser-Ebene abzufangen:
typescript
import { test, expect } from '@playwright/test'

test('shows error when subscription API fails', async ({ page }) => {
  await page.route('/api/payments/subscription', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Service unavailable' }),
    })
  })

  await page.goto('/dashboard/billing')
  await expect(page.getByText(/unable to load subscription/i)).toBeVisible()
})

Neue Handler hinzufügen

1

Handler-Datei erstellen

Erstelle eine neue Datei in apps/boilerplate/src/mocks/ gemäß der Namenskonvention <feature>-handlers.ts:
typescript
// src/mocks/search-handlers.ts
import { http, HttpResponse } from 'msw'

export const searchHandlers = [
  http.get('/api/search', ({ request }) => {
    const url = new URL(request.url)
    const query = url.searchParams.get('q') || ''

    return HttpResponse.json({
      results: [
        { id: '1', title: `Result for "${query}"`, score: 0.95 },
      ],
      total: 1,
    })
  }),
]
2

In handlers.ts registrieren

Die neuen Handler importieren und in das Haupt-Handler-Array streuen:
typescript
// src/mocks/handlers.ts
import { searchHandlers } from './search-handlers'

export const handlers = [
  ...searchHandlers,
  // ... bestehende Handler
]
3

Mit Pro-Test-Überschreibungen testen

Tests schreiben, die den Standard-Handler verwenden und Randfälle überschreiben:
typescript
import { server } from '@/mocks/server'
import { http, HttpResponse } from 'msw'

describe('Search', () => {
  it('returns results from default handler', async () => {
    const res = await fetch('/api/search?q=test')
    const data = await res.json()
    expect(data.results).toHaveLength(1)
  })

  it('handles empty results', async () => {
    server.use(
      http.get('/api/search', () => {
        return HttpResponse.json({ results: [], total: 0 })
      })
    )

    const res = await fetch('/api/search?q=nothing')
    const data = await res.json()
    expect(data.results).toHaveLength(0)
  })
})

Demo-Modus-Integration

Handler-Dateien integrieren sich mit dem demoCreditStore, um realistischen Credit-Abzug im Demo-Modus bereitzustellen. Die AI-Handler prüfen das Credit-Guthaben vor der Verarbeitung von Anfragen:
typescript
// In ai-handlers.ts (vereinfacht)
http.post('/api/ai/chat', async ({ request }) => {
  const { credits } = demoCreditStore.getState()

  if (credits <= 0) {
    return HttpResponse.json(
      { error: 'No credits remaining' },
      { status: 402 }
    )
  }

  // Credits basierend auf geschätztem Token-Verbrauch abziehen
  demoCreditStore.getState().deductCredits(10)

  // Streaming-Response zurückgeben...
})
Dies ermöglicht es dem Demo-Modus der Marketing-Site, realistisches Credit-Abzugs-Verhalten anzuzeigen, ohne Backend-Services zu benötigen.

Umgebungskonfiguration

VariableStandardZweck
NEXT_PUBLIC_MSW_ENABLEDfalseMSW im Browser aktivieren (in apps/boilerplate/.env.test auf true gesetzt)
DISABLE_REPOSITORY_MOCKSfalseRepository-Schicht in Unit-Tests deaktivieren (in apps/boilerplate/vitest.config.ts auf true gesetzt)
NODE_ENVMSW-Server-Modus aktiviert sich, wenn NODE_ENV === 'test'

MSW-Lifecycle in Tests

Der MSW-Server-Lifecycle wird in apps/boilerplate/src/test/setup.ts verwaltet:
typescript
// Vor allen Tests — Server starten
beforeAll(() => {
  server.listen({ onUnhandledRequest: 'warn' })
})

// Nach jedem Test — Überschreibungen zurücksetzen
afterEach(() => {
  cleanup()
  server.resetHandlers()
})

// Nach allen Tests — Server stoppen
afterAll(() => {
  server.close()
})

Fehlerbehebung

Warnungen bei nicht behandelten Anfragen

Wenn du [MSW] Warning: intercepted a request without a matching request handler siehst, bedeutet das, dass ein Test einen API-Aufruf macht, den kein Handler abdeckt. Lösungen:
  1. Einen Handler hinzufügen in der entsprechenden Handler-Datei
  2. Pro-Test überschreiben mit server.use(), wenn der Endpunkt testspezifisch ist
  3. URL prüfen — Tippfehler in Endpunkt-Pfaden sind die häufigste Ursache

Handler-Reihenfolge

MSW gleicht Handler in Array-Reihenfolge ab (erster Treffer gewinnt). Wenn du einen generischen Handler wie http.get('/api/*') hast, platziere ihn nach spezifischeren Handlern:
typescript
export const handlers = [
  http.get('/api/user/:id', specificHandler),  // Zuerst geprüft
  http.get('/api/user', listHandler),          // Danach geprüft
  // Catch-all-Handler nicht vor spezifische setzen
]

Veraltete Überschreibungen zwischen Tests

Wenn Tests mit unerwarteten Daten fehlschlagen, prüfe, ob server.resetHandlers() in afterEach aufgerufen wird. Ohne dies bleiben server.use()-Überschreibungen über Tests hinweg bestehen. Die Setup-Datei konfiguriert dies automatisch, aber benutzerdefinierte Test-Setups könnten es übersehen.

MSW fängt im Browser nicht ab

Wenn der Entwicklungsserver keine Anfragen abfängt:
  1. Prüfe, ob NEXT_PUBLIC_MSW_ENABLED=true in apps/boilerplate/.env.local gesetzt ist
  2. Prüfe, ob apps/boilerplate/public/mockServiceWorker.js existiert (führe cd apps/boilerplate && npx msw init public/ aus, um es neu zu generieren)
  3. Suche nach der Konsolenmeldung MSW (Mock Service Worker) started successfully

Wichtige Dateien

DateiZweck
apps/boilerplate/src/mocks/handlers.tsHaupt-Handler-Aggregator mit grundlegenden API-Handlern
apps/boilerplate/src/mocks/server.tsNode.js-MSW-Server für Vitest
apps/boilerplate/src/mocks/browser.tsBrowser-MSW-Worker für E2E und Entwicklung
apps/boilerplate/src/mocks/index.tsBarrel-Exporte und isMSWEnabled()-Helper
apps/boilerplate/src/mocks/ai-handlers.tsAI-Chat, RAG, Streaming, Credit-Abzug
apps/boilerplate/src/mocks/lemonsqueezy-handlers.tsPayment-Subscriptions, Produkte, Tier-Berechnung
apps/boilerplate/src/mocks/demo-handlers.tsDemo-Modus tier-aware Responses
apps/boilerplate/src/test/setup.tsMSW-Lifecycle (Start, Reset, Schließen)