When you work on a project that uses a third-party authentication provider, you will inevitably face this question: how do I run my Playwright tests without dealing with real login flows? Real authentication involves browser redirects, multi-factor prompts, token exchange - none of which you want in your fast, deterministic UI tests.
I spent some time exploring different approaches and ended up with a solution that I really like. The mock logic never reaches the production bundle. Zero if (isTest) branches, zero factory patterns with test flags, zero conditional imports. The production code has absolutely no idea it's being tested. Let me walk you through it.
When do you need auth mocking at all?
If your application has an authentication layer (and most enterprise apps do), your Playwright tests will struggle without some form of mocking. Here is why:
Real login flows are slow and flaky. They involve redirects to external identity providers, sometimes MFA, sometimes consent screens. This adds seconds to every test and introduces network-dependent failures.
You can't control the state. What if you need to test what happens when a token expires? Or when the session is invalid? With real auth, you'd have to wait for tokens to actually expire or manipulate cookies manually. Not fun.
CI environments don't have credentials. You'd need test accounts, secrets in your pipeline, and a stable connection to the identity provider. That's a lot of infrastructure for a UI test.
The auth library loads external JavaScript. In a Playwright context, the browser loads the auth library from a CDN or bundle. If you block it (which Playwright might do depending on your setup), the app crashes before rendering anything.
So the goal is clear: replace the auth service with a mock that you can control from your tests. The real question is how to do it cleanly.
The alternatives I considered
1. Runtime flags and conditional imports
The most straightforward approach: check an environment variable or a URL parameter and load a different service. To keep mock code out of the production bundle, use dynamic imports so the bundler can split them into separate chunks:
1// DON'T do this - static imports always end up in the bundle 2const authService = import.meta.env.VITE_USE_MOCK_AUTH 3 ? new MockAuthService() 4 : new OAuthService() 5 6// Slightly better - dynamic imports let the bundler exclude the mock chunk 7const authModule = import.meta.env.VITE_USE_MOCK_AUTH 8 ? await import('./MockAuthService') 9 : await import('./OAuthService') 10const authService = new authModule.default()
Even with dynamic imports, the runtime if branch still exists in production. The flag lives in your environment config - one misconfiguration and real users hit the mock. And if you need to test different auth states (expired tokens, failed refresh), you're back to adding more flags or URL parameters. It grows messy fast.
2. Playwright's page.route() to intercept token endpoints
Another approach: let the real auth library load, but intercept all HTTP requests to the identity provider and return fake tokens.
1// DON'T do this
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})
This can work for simple setups, but auth providers often do a lot internally - they cache tokens in sessionStorage, validate them, check expiration. You'd have to mock not just the HTTP layer but also the storage and the internal state machine. It's brittle and breaks whenever the auth library updates its internals.
3. Dependency injection with a factory pattern
Some teams introduce a factory that returns either the real or mock service based on a configuration:
1// DON'T do this
2// authServiceFactory.ts
3export function createAuthService(): IAuthService {
4 if (config.useMockAuth) {
5 return new MockAuthService()
6 }
7 return new OAuthService()
8}
Better than approach #1 in terms of organisation, but the factory itself is production code that knows about testing. You could use dynamic imports to keep the mock out of the bundle, but the core problem remains: the factory has a branch that exists solely for tests. That's test awareness leaking into production.
4. Build-time file swapping (what I chose)
This is the approach I went with: use the bundler's resolve alias to swap the entire auth service file at build time. The production build uses authService.ts. The test build uses authService.playwright.ts. They export the same function signature, but the implementation is completely different. No runtime checks, no shared code, no test logic in production.
Why do I like this the most? Because the separation is absolute. The production code doesn't import MockAuthService. It doesn't know MockAuthService exists. The swap happens at the bundler level, before any JavaScript runs. It's as clean as it gets.
How it works - step by step
The code examples below are from a Vue 3 + Vite project, but the pattern works with any framework - more on that later. The implementation has three parts: the production auth service, the Playwright replacement, and the test fixture that controls it from Playwright.
Step 1: Define a shared interface
Both the real and mock auth services implement the same interface. This is the contract that the rest of the application depends on:
1// auth.types.ts - shared contract
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}
This file is imported by both the real and mock implementations, so it lives on its own - not inside the production auth service.
Step 2: Create the production auth service entry point
The production file exports a createAuthService function. This is what the app imports:
1// authService.ts - production entry point
2import { OAuthService } from './OAuthService'
3import type { IAuthService } from './auth.types'
4
5export function createAuthService(): IAuthService {
6 return new OAuthService()
7}
Step 3: Create the Playwright replacement
This file has the exact same export signature, but instead of creating an OAuthService, it reads from window.__PLAYWRIGHT_MOCK_AUTH__ - an object that Playwright injects before the app starts:
1// authService.playwright.ts - test entry point
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}
The MockAuthService is a simple class that reads the injected state and behaves accordingly - no real auth calls, no network requests, no redirects:
1// MockAuthService.ts (simplified)
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 // ... handle other states like SESSION_EXPIRED, AUTH_FAILED, etc.
23 }
24
25 async refreshTokens() { /* extend as needed */ }
26 async redirectToLogin() { /* no-op in tests */ }
27 async redirectToLogout() { this._state = { type: 'UNAUTHENTICATED' } }
28
29 get state() { return this._state }
30}
Step 4: Configure the bundler to swap files
In Vite, this is a one-liner in vite.config.ts. When you start the dev server with --mode playwright, the resolve alias kicks in:
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}))
Now when you run npm run dev -- --mode playwright, every import of @/services/auth resolves to authService.playwright.ts instead of authService.ts. The real auth code is never loaded.
Step 5: Inject auth state from Playwright tests
On the Playwright side, you inject the mock auth state before navigating to the page:
1// auth.fixture.ts (simplified)
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}
And your tests use it like this:
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 // Assert login redirect behaviour 11})
You can test any auth state - authenticated, unauthenticated, token expired, config failed - all from the test side, without touching the app.
Can you do this in Angular or React?
Yes, the same principle applies - every major bundler has an equivalent of Vite's resolve alias. In webpack (used by Create React App and Angular CLI under the hood), you can use resolve.alias in your webpack config or the NormalModuleReplacementPlugin for more dynamic swaps. If you're on Next.js, the same resolve.alias field works in next.config.js. The pattern stays the same regardless of your framework: define an interface, create two implementations, swap them at build time, inject test state via window.
Wrapping up
The build-time file swap approach gives you the cleanest separation I've found: your production bundle contains zero test logic - no if branches, no factory patterns, no mock imports. The swap happens before any JavaScript runs, so the production application genuinely does not know it's being tested. You still get full control over every auth state from the test side.
Your production code doesn't need to know. Keep it that way.
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Blog author
Maryna Tochkova
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.