Beliebte Suchanfragen
//

Auth-Mocking in Playwright richtig gemacht: Keine Runtime-Flags, keine Factory-Patterns, keine Kompromisse

28.5.2026 | 7 Minuten Lesezeit

Wenn du an einem Projekt arbeitest, das einen externen Authentication-Provider nutzt, wirst du unweigerlich vor dieser Frage stehen: Wie führe ich meine Playwright-Tests aus, ohne mich mit echten Login-Flows herumschlagen zu müssen? Echte Authentifizierung bedeutet Browser-Redirects, Multi-Faktor-Prompts, Token-Exchange - nichts davon willst du in deinen schnellen, deterministischen UI-Tests.

Ich habe einige Zeit damit verbracht, verschiedene Ansätze auszuprobieren, und bin bei einer Lösung gelandet, die mir wirklich gut gefällt. Die Mock-Logik erreicht nie das Production-Bundle. Null if (isTest)-Branches, null Factory-Patterns mit Test-Flags, null bedingte Imports. Der Produktionscode hat absolut keine Ahnung, dass er getestet wird. Lass mich dich durch die Lösung führen.

Wann braucht man Auth-Mocking überhaupt?

Wenn deine Anwendung eine Authentifizierungsschicht hat (und die meisten Enterprise-Apps haben eine), werden deine Playwright-Tests ohne irgendeine Form von Mocking Probleme haben. Hier ist warum:

Echte Login-Flows sind langsam und instabil. Sie beinhalten Redirects zu externen Identity-Providern, manchmal MFA, manchmal Consent-Screens. Das fügt jedem Test Sekunden hinzu und führt zu netzwerkabhängigen Fehlern.

Du kannst den State nicht kontrollieren. Was, wenn du testen musst, was passiert, wenn ein Token abläuft? Oder wenn die Session ungültig ist? Mit echter Auth müsstest du warten, bis Tokens tatsächlich ablaufen, oder Cookies manuell manipulieren. Kein Spaß.

CI-Umgebungen haben keine Credentials. Du bräuchtest Test-Accounts, Secrets in deiner Pipeline und eine stabile Verbindung zum Identity-Provider. Das ist eine Menge Infrastruktur für einen UI-Test.

Die Auth-Library lädt externes JavaScript. Im Playwright-Kontext lädt der Browser die Auth-Library von einem CDN oder Bundle. Wenn du das blockierst (was Playwright je nach Setup tun kann), crasht die App, bevor sie irgendetwas rendert.

Das Ziel ist also klar: Den Auth-Service durch einen Mock ersetzen, den du aus deinen Tests heraus kontrollieren kannst. Die eigentliche Frage ist, wie man das sauber macht.

Die Alternativen, die ich in Betracht gezogen habe

1. Runtime-Flags und bedingte Imports

Der naheliegendste Ansatz: Eine Umgebungsvariable oder einen URL-Parameter prüfen und einen anderen Service laden. Um Mock-Code aus dem Production-Bundle herauszuhalten, nutze dynamische Imports, damit der Bundler sie in separate Chunks aufteilen kann:

1// NICHT so machen - statische Imports landen immer im Bundle
2const authService = import.meta.env.VITE_USE_MOCK_AUTH
3  ? new MockAuthService()
4  : new OAuthService()
5
6// Etwas besser - dynamische Imports erlauben dem Bundler, den Mock-Chunk auszuschließen
7const authModule = import.meta.env.VITE_USE_MOCK_AUTH
8  ? await import('./MockAuthService')
9  : await import('./OAuthService')
10const authService = new authModule.default()

Selbst mit dynamischen Imports existiert der Runtime-if-Branch weiterhin in Production. Das Flag lebt in deiner Umgebungskonfiguration - eine Fehlkonfiguration und echte Nutzer treffen auf den Mock. Und wenn du verschiedene Auth-States testen musst (abgelaufene Tokens, fehlgeschlagener Refresh), bist du wieder dabei, mehr Flags oder URL-Parameter hinzuzufügen. Das wird schnell unübersichtlich.

2. Playwrights page.route() zum Abfangen von Token-Endpoints

Ein anderer Ansatz: Die echte Auth-Library laden lassen, aber alle HTTP-Requests zum Identity-Provider abfangen und gefälschte Tokens zurückgeben.

1// NICHT so machen
2await page.route('https://login.example.com/oauth/token', route => {
3  route.fulfill({
4    status: 200,
5    body: JSON.stringify({ access_token: 'fake-token', expires_in: 3600 }),
6  })
7})

Das kann bei einfachen Setups funktionieren, aber Auth-Provider machen intern oft eine Menge - sie cachen Tokens in sessionStorage, validieren sie, prüfen die Ablaufzeit. Du müsstest nicht nur die HTTP-Schicht mocken, sondern auch den Storage und die interne State-Machine. Das ist fragil und bricht, sobald die Auth-Library ihre Interna aktualisiert.

3. Dependency Injection mit einem Factory-Pattern

Manche Teams führen eine Factory ein, die je nach Konfiguration entweder den echten oder den Mock-Service zurückgibt:

1// NICHT so machen
2// authServiceFactory.ts
3export function createAuthService(): IAuthService {
4  if (config.useMockAuth) {
5    return new MockAuthService()
6  }
7  return new OAuthService()
8}

Besser als Ansatz #1 in Bezug auf Organisation, aber die Factory selbst ist Produktionscode, der von Tests weiß. Man könnte dynamische Imports verwenden, um den Mock aus dem Bundle herauszuhalten, aber das Kernproblem bleibt: Die Factory hat einen Branch, der ausschließlich für Tests existiert. Das ist Test-Awareness, die in den Produktionscode durchsickert.

4. Build-Time File-Swapping (meine Wahl)

Das ist der Ansatz, für den ich mich entschieden habe: Den Resolve-Alias des Bundlers nutzen, um die gesamte Auth-Service-Datei zur Build-Zeit auszutauschen. Der Production-Build verwendet authService.ts. Der Test-Build verwendet authService.playwright.ts. Sie exportieren die gleiche Funktionssignatur, aber die Implementierung ist komplett unterschiedlich. Keine Runtime-Checks, kein geteilter Code, keine Test-Logik in Production.

Warum gefällt mir das am besten? Weil die Trennung absolut ist. Der Produktionscode importiert MockAuthService nicht. Er weiß nicht, dass MockAuthService existiert. Der Austausch passiert auf Bundler-Ebene, bevor irgendein JavaScript ausgeführt wird. Sauberer geht es nicht.

So funktioniert es - Schritt für Schritt

Die Code-Beispiele stammen aus einem Vue 3 + Vite Projekt, aber das Pattern funktioniert mit jedem Framework - dazu später mehr. Die Implementierung besteht aus drei Teilen: dem Production-Auth-Service, dem Playwright-Ersatz und dem Test-Fixture, das es von Playwright aus steuert.

Schritt 1: Ein gemeinsames Interface definieren

Sowohl der echte als auch der Mock-Auth-Service implementieren das gleiche Interface. Das ist der Vertrag, von dem der Rest der Anwendung abhängt:

1// auth.types.ts - gemeinsamer Vertrag
2export type AuthState =
3  | { type: 'AUTHENTICATED'; accessToken: string; accountInfo: UserAccount; tokenExpirationDate: Date }
4  | { type: 'UNAUTHENTICATED' }
5  | { type: 'SESSION_EXPIRED' }
6  | { type: 'AUTH_FAILED' }
7
8export interface IAuthService {
9  initialize: () => Promise<void>
10  refreshTokens: () => Promise<void>
11  redirectToLogin: () => Promise<void>
12  redirectToLogout: () => Promise<void>
13  readonly state: AuthState
14}

Diese Datei wird von beiden Implementierungen importiert und lebt deshalb eigenständig - nicht innerhalb des Production-Auth-Service.

Schritt 2: Den Production-Auth-Service-Einstiegspunkt erstellen

Die Production-Datei exportiert eine createAuthService-Funktion. Das ist, was die App importiert:

1// authService.ts - Production-Einstiegspunkt
2import { OAuthService } from './OAuthService'
3import type { IAuthService } from './auth.types'
4
5export function createAuthService(): IAuthService {
6  return new OAuthService()
7}

Schritt 3: Den Playwright-Ersatz erstellen

Diese Datei hat exakt die gleiche Export-Signatur, aber statt einen OAuthService zu erstellen, liest sie von window.__PLAYWRIGHT_MOCK_AUTH__ - ein Objekt, das Playwright injiziert, bevor die App startet:

1// authService.playwright.ts - Test-Einstiegspunkt
2import { MockAuthService } from './MockAuthService'
3
4export function createAuthService(): IAuthService {
5  const mockAuth = window.__PLAYWRIGHT_MOCK_AUTH__
6
7  if (!mockAuth) {
8    throw new Error(
9      'Playwright mode active but window.__PLAYWRIGHT_MOCK_AUTH__ not found. ' +
10      'Inject mock auth via page.addInitScript().'
11    )
12  }
13
14  return new MockAuthService(mockAuth)
15}

Der MockAuthService ist eine einfache Klasse, die den injizierten State liest und sich entsprechend verhält - keine echten Auth-Calls, keine Netzwerk-Requests, keine Redirects:

1// MockAuthService.ts (vereinfacht)
2export class MockAuthService implements IAuthService {
3  private _state: AuthState = { type: 'UNAUTHENTICATED' }
4
5  constructor(
6    private mockAuth: {
7      state: string
8      accountInfo?: UserAccount
9      tokenExpiration?: string
10    },
11  ) {}
12
13  async initialize() {
14    if (this.mockAuth.state === 'AUTHENTICATED') {
15      this._state = {
16        type: 'AUTHENTICATED',
17        accessToken: 'mock-token',
18        accountInfo: this.mockAuth.accountInfo!,
19        tokenExpirationDate: new Date(this.mockAuth.tokenExpiration!),
20      }
21    }
22    // ... andere States wie SESSION_EXPIRED, AUTH_FAILED etc. behandeln
23  }
24
25  async refreshTokens() { /* nach Bedarf erweitern */ }
26  async redirectToLogin() { /* No-Op in Tests */ }
27  async redirectToLogout() { this._state = { type: 'UNAUTHENTICATED' } }
28
29  get state() { return this._state }
30}

Schritt 4: Den Bundler für den Dateiaustausch konfigurieren

In Vite ist das ein Einzeiler in vite.config.ts. Wenn du den Dev-Server mit --mode playwright startest, greift der Resolve-Alias:

1// vite.config.ts
2export default defineConfig(({ mode }) => ({
3  resolve: {
4    alias: {
5      ...(mode === 'playwright' && {
6        '@/services/auth': fileURLToPath(
7          new URL('./src/services/authService.playwright.ts', import.meta.url)
8        ),
9      }),
10      '@': fileURLToPath(new URL('./src', import.meta.url)),
11    }
12  }
13}))

Wenn du jetzt npm run dev -- --mode playwright ausführst, wird jeder Import von @/services/auth zu authService.playwright.ts aufgelöst statt zu authService.ts. Der echte Auth-Code wird nie geladen.

Schritt 5: Auth-State aus Playwright-Tests injizieren

Auf der Playwright-Seite injizierst du den Mock-Auth-State, bevor du zur Seite navigierst:

1// auth.fixture.ts (vereinfacht)
2export async function mockAuth(page: Page, state: AuthState['type']): Promise<void> {
3  await page.addInitScript((mockAuth) => {
4    window.__PLAYWRIGHT_MOCK_AUTH__ = mockAuth
5  }, {
6    state,
7    accountInfo: { username: 'test.user@example.com', name: 'Test User' },
8    tokenExpiration: new Date(Date.now() + 3600000).toISOString(),
9  })
10}

Und deine Tests nutzen es so:

1test('shows the dashboard when user is authenticated', async ({ page }) => {
2  await mockAuth(page, 'AUTHENTICATED')
3  await page.goto('/')
4  await expect(page.getByText('Welcome, Test User')).toBeVisible()
5})
6
7test('redirects to login when session expired', async ({ page }) => {
8  await mockAuth(page, 'SESSION_EXPIRED')
9  await page.goto('/')
10  // Login-Redirect-Verhalten prüfen
11})

Du kannst jeden Auth-State testen - authentifiziert, nicht authentifiziert, Token abgelaufen, Konfiguration fehlgeschlagen - alles von der Test-Seite aus, ohne die App anzufassen.

Geht das auch in Angular oder React?

Ja, das gleiche Prinzip gilt - jeder große Bundler hat ein Äquivalent zu Vites Resolve-Alias. In webpack (das unter der Haube von Create React App und Angular CLI verwendet wird) kannst du resolve.alias in deiner webpack-Konfiguration oder das NormalModuleReplacementPlugin für dynamischere Austausche nutzen. Bei Next.js funktioniert das gleiche resolve.alias-Feld in next.config.js. Das Pattern bleibt unabhängig vom Framework gleich: Interface definieren, zwei Implementierungen erstellen, zur Build-Zeit austauschen, Test-State über window injizieren.

Fazit

Der Build-Time-File-Swap-Ansatz ist die sauberste Trennung, die ich gefunden habe: Dein Production-Bundle enthält null Test-Logik - keine if-Branches, keine Factory-Patterns, keine Mock-Imports. Der Austausch passiert, bevor irgendein JavaScript ausgeführt wird, also weiß die Produktionsanwendung wirklich nicht, dass sie getestet wird. Trotzdem hast du von der Test-Seite aus volle Kontrolle über jeden Auth-State.

Dein Produktionscode muss es nicht wissen. Und das soll auch so bleiben.

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//
Jetzt für unseren Newsletter anmelden

Alles Wissenswerte auf einen Klick:
Unser Newsletter bietet dir die Möglichkeit, dich ohne großen Aufwand über die aktuellen Themen bei codecentric zu informieren.