Bypass Supabase Auth for Playwright E2E Testing Without Login
Encountered this issue while building an AI Agent SaaS platform for a client. Here's the root cause and solution.
TL;DRโ
E2E tests shouldn't depend on real OAuth login flows. By detecting localStorage test markers in the useAuth hook, you can inject mock auth state directly and skip Supabase initialization. Also change the Zustand store's loading default to false to prevent AuthGuard from showing an infinite spinner.
Problemโ
When testing a React SPA with Playwright, pages are protected by AuthGuard and require Supabase authentication. After the test starts, the page shows a loading spinner indefinitely and never reaches the business logic.
// AuthGuard component - tests get stuck here
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <Spinner /> // Forever showing spinner
}
if (!isAuthenticated) {
return <Navigate to="/login" />
}
return <>{children}</>
}
Test code tries to simulate login, but Supabase Auth SDK's internal state can't be controlled by simple API mocking.
Root Causeโ
1. Supabase Auth Initializes Asynchronouslyโ
The useAuth hook calls supabase.auth.getSession() in useEffect, which is async. In test environments, network requests may fail or timeout, leaving state stuck at loading: true.
2. Zustand Store Default Value Problemโ
// authStore.ts - problematic code
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: true, // ๐ Default is true
// ...
}),
{ name: 'auth-storage' }
)
)
At test startup: loading: true + async init failure = forever loading.
3. OAuth Flow Can't Be Automatedโ
Even if APIs can be mocked, OAuth redirect flows require real browser interaction that E2E tests can't reliably simulate.
Solutionโ
Step 1: Add Test Mode Detection in useAuth Hookโ
// hooks/useAuth.ts
export function useAuth() {
const { user, token, loading, setUser, setToken, setLoading } = useAuthStore()
useEffect(() => {
const initAuth = async () => {
// ๐ Check test mode first
const testAuthUser = localStorage.getItem('test-auth-user')
const testAuthToken = localStorage.getItem('test-auth-token')
if (testAuthUser && testAuthToken) {
try {
const userData = JSON.parse(testAuthUser) as User
setUser(userData)
setToken(testAuthToken)
setLoading(false)
console.log('[useAuth] Using test mode auth')
return // ๐ Return early, skip Supabase init
} catch (e) {
console.error('Failed to parse test auth user:', e)
}
}
// ๐ Normal mode: proceed with Supabase Auth
try {
const { data: { session } } = await supabase.auth.getSession()
if (session) {
setUser(session.user as User)
setToken(session.access_token)
}
} catch (error) {
console.error('Auth init failed:', error)
} finally {
setLoading(false)
}
}
initAuth()
// ๐ Skip auth state listener in test mode
if (localStorage.getItem('test-auth-user')) {
return
}
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
// ... normal auth state handling
}
)
return () => subscription.unsubscribe()
}, [])
}
Step 2: Modify Zustand Store Default Valueโ
// stores/authStore.ts
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: false, // ๐ Change to false, let useAuth hook control state
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
setLoading: (loading) => set({ loading }),
logout: () => set({ user: null, token: null, loading: false }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user, token: state.token }),
}
)
)
Step 3: Inject Test Auth in Playwright Fixtureโ
// e2e/fixtures.ts
import { test as base } from '@playwright/test'
export const mockUser = {
id: 'test-user-id',
email: '[email protected]',
created_at: '2024-01-01T00:00:00Z',
}
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
// Visit page first to set localStorage origin
await page.goto('/login')
// ๐ Inject test auth state into localStorage
await page.evaluate(
({ user, token }) => {
localStorage.setItem('test-auth-user', JSON.stringify(user))
localStorage.setItem('test-auth-token', token)
},
{ user: mockUser, token: 'mock-access-token' }
)
// Navigate to protected page, useAuth will detect test mode
await page.goto('/dashboard')
await use(page)
},
})
Step 4: Use in Testsโ
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'
test('dashboard shows user agents', async ({ authenticatedPage }) => {
// authenticatedPage is already authenticated, no login needed
await expect(authenticatedPage.getByText('Test Agent')).toBeVisible()
})
Complete Code Structureโ
agent-frontend/
โโโ e2e/
โ โโโ fixtures.ts # Playwright fixture + mock data
โ โโโ dashboard.spec.ts # Test cases
โ โโโ ...
โโโ src/
โ โโโ hooks/
โ โ โโโ useAuth.ts # Test mode detection
โ โโโ stores/
โ โโโ authStore.ts # loading: false default
โโโ playwright.config.ts
Key Takeawaysโ
- Use special prefix for test mode keys:
test-auth-*won't appear in production - Check before initialize: Check localStorage first, then proceed with Supabase Auth
- Skip auth listener: No need to listen for auth state changes in test mode
- Change loading default to false: Let the hook explicitly control loading state
Interested in similar solutions? Contact us