Skip to main content

3 posts tagged with "E2E Testing"

View all tags

Full API Mocking with Playwright page.route()

· 4 min read

Encountered this issue while building an AI Agent platform. Here's the root cause and solution.

TL;DR

Use page.route() to intercept all API requests and return predefined mock data. Tests don't depend on real backend, can run stably in any environment, and avoid side effects like creating/deleting data.

Problem

E2E tests calling real API:

// ❌ Depends on real backend
test('create agent', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

// Click create button
await authenticatedPage.click('button:has-text("Create")')

// Fill form
await authenticatedPage.fill('input[name="name"]', 'Test Agent')
await authenticatedPage.click('button[type="submit"]')

// Wait for API response
await authenticatedPage.waitForTimeout(2000)

// Verify... but what if backend is down? DB connection failed?
})

Problems:

  1. Depends on backend state — Tests fail when backend is down
  2. Data side effects — Each run creates real data
  3. Not repeatable — Data changes cause assertion failures
  4. CI environment issues — Need full backend service running

Solution

1. Define Mock Data

// e2e/fixtures.ts
export const mockAgents = [
{
agent_id: 'agent-1',
user_id: 'test-user-id',
name: 'Test Agent 1',
skills: [],
mcp_tools: [],
llm_config: { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' },
risk_threshold: 'medium',
auto_confirm_low: true,
created_at: '2024-01-01T00:00:00Z',
},
]

export const mockSkills = [
{
skill_id: 'skill-1',
owner_id: 'test-user-id',
name: 'Test Skill',
system_prompt: 'You are helpful.',
is_public: false,
is_own: true,
},
]

2. Setup API Mock

export async function setupMockApi(page: Page) {
// Mock GET /api/agents
await page.route('**/api/agents', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAgents),
})
} else if (route.request().method() === 'POST') {
// Simulate creation
const body = route.request().postDataJSON()
const newAgent = {
agent_id: `agent-${Date.now()}`,
user_id: 'test-user-id',
name: body.name,
created_at: new Date().toISOString(),
...body,
}
mockAgents.push(newAgent)
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(newAgent),
})
}
})

// Mock GET/PATCH/DELETE /api/agents/:id
await page.route('**/api/agents/*', async (route) => {
const url = route.request().url()
const match = url.match(/\/api\/agents\/([^/]+)/)
const agentId = match?.[1]

if (route.request().method() === 'GET') {
const agent = mockAgents.find((a) => a.agent_id === agentId)
await route.fulfill({
status: agent ? 200 : 404,
contentType: 'application/json',
body: JSON.stringify(agent || { error: 'Not found' }),
})
} else if (route.request().method() === 'DELETE') {
const index = mockAgents.findIndex((a) => a.agent_id === agentId)
if (index !== -1) mockAgents.splice(index, 1)
await route.fulfill({ status: 204 })
}
})

// Mock /api/skills
await page.route('**/api/skills**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockSkills),
})
})

// Mock /api/health
await page.route('**/api/health', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok' }),
})
})
}

3. Use in Fixture

export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page)
await setupMockApi(page) // Intercept all API
await use(page)
},
})

4. Use in Tests

// e2e/dashboard.spec.ts
import { test, expect, mockAgents } from './fixtures'

test('should display agent list', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

// API automatically mocked, returns mockAgents
const agentCards = authenticatedPage.locator('[data-testid="agent-card"]')

// Assertion based on known mock data
await expect(agentCards).toHaveCount(mockAgents.length)
})

test('should create new agent', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

await authenticatedPage.click('button:has-text("Create")')

const dialog = authenticatedPage.locator('[role="dialog"]')
await dialog.locator('input[name="name"]').fill('New Agent')
await dialog.locator('button[type="submit"]').click()

// POST /api/agents mocked, returns 201
await authenticatedPage.waitForTimeout(500)

// Verify UI update
await expect(authenticatedPage.locator('text=New Agent')).toBeVisible()
})

Core Techniques

URL Matching Patterns

// Exact match
await page.route('**/api/agents', handler)

// Wildcard match
await page.route('**/api/agents/**', handler)

// Regex match
await page.route(/\/api\/agents\/\d+/, handler)

Reading Request Body

await page.route('**/api/agents', async (route) => {
const body = route.request().postDataJSON()
console.log('Request body:', body)

// Return different responses based on request content
if (body.name === 'error-test') {
await route.fulfill({ status: 400, body: JSON.stringify({ error: 'Bad request' }) })
} else {
await route.fulfill({ status: 201, body: JSON.stringify({ id: 'new-id', ...body }) })
}
})

Simulating Error Scenarios

// Network error
await page.route('**/api/agents', (route) => route.abort('failed'))

// Timeout
await page.route('**/api/agents', async (route) => {
await new Promise((r) => setTimeout(r, 30000))
route.continue()
})

// 500 error
await page.route('**/api/agents', (route) =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal error' }) })
)

Partial Pass-through

// Only mock specific API, pass through others
await page.route('**/api/**', async (route) => {
const url = route.request().url()

if (url.includes('/api/agents')) {
await route.fulfill({ status: 200, body: JSON.stringify(mockAgents) })
} else {
await route.continue() // Other APIs use real requests
}
})

Mock Data Management

// Centralize all mock data
// e2e/fixtures.ts
export const mockData = {
agents: [...],
skills: [...],
apiKeys: [...],
tasks: [...],
}

// Reset before each test
test.beforeEach(() => {
Object.assign(mockData, JSON.parse(JSON.stringify(originalMockData)))
})

Key Principles

  1. Mock all external dependencies — API, OAuth, third-party services
  2. Match real data structures — Mock data should match API contract
  3. Cover success and failure — Test 200/400/500 scenarios
  4. Isolate test data — Each test uses independent mock data copy

Interested in similar solutions? Contact us

Skip Login in Playwright Tests with Custom Fixtures

· 3 min read

Encountered this issue while building an AI Agent platform. Here's the root cause and solution.

TL;DR

Use Playwright's test.extend() to create a custom fixture that injects auth token into localStorage via page.addInitScript() before page load. Tests use authenticatedPage instead of page, automatically getting logged-in state without repeating login in each test.

Problem

E2E tests need to verify pages behind authentication:

// ❌ Every test goes through login
test('dashboard', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
// Finally can start testing...
await expect(page.locator('h1')).toBeVisible()
})

Problems:

  1. Repeated login in every test — Wastes time, slows CI
  2. Depends on real auth service — Tests fail when Supabase Auth is unavailable
  3. State pollution between tests — Login state may affect other tests

Solution

1. Create Custom Fixture

// e2e/fixtures.ts
import { test as base, expect, Page } from '@playwright/test'

// Mock user data
export const mockUser = {
id: 'test-user-id',
email: 'test@example.com',
aud: 'authenticated',
role: 'authenticated',
}

export const mockAccessToken = 'mock-access-token-for-testing'

// Inject auth token into localStorage
export async function setupMockAuth(page: Page) {
await page.addInitScript(
({ user, accessToken }) => {
const mockSession = {
access_token: accessToken,
refresh_token: 'mock-refresh-token',
expires_in: 3600,
expires_at: Math.floor(Date.now() / 1000) + 3600,
token_type: 'bearer',
user,
}
// Supabase auth token key format: sb-{project}-auth-token
localStorage.setItem('sb-placeholder-auth-token', JSON.stringify(mockSession))
},
{ user: mockUser, accessToken: mockAccessToken }
)
}

// Extend fixture
export const test = base.extend<{
authenticatedPage: Page
}>({
authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page)
await use(page)
},
})

export { expect }

2. Use authenticatedPage in Tests

// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'

test.describe('Dashboard', () => {
test('should display welcome message', async ({ authenticatedPage }) => {
// Direct access to protected page, no login needed
await authenticatedPage.goto('/dashboard')

await expect(authenticatedPage.locator('h1')).toContainText('Welcome')
})

test('should show agent list', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')

const agents = authenticatedPage.locator('[data-testid="agent-card"]')
await expect(agents.first()).toBeVisible()
})
})

3. Comparison: Unauthenticated vs Authenticated

// Unauthenticated test (redirects to login)
test('redirects to login when not authenticated', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForTimeout(500)

expect(page.url()).toContain('/login')
})

// Authenticated test (direct dashboard access)
test('allows access when authenticated', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
await authenticatedPage.waitForTimeout(500)

expect(authenticatedPage.url()).not.toContain('/login')
})

Core Principles

addInitScript vs Setting After Page Load

// ❌ Wrong: Set after page load (may have already redirected)
await page.goto('/dashboard')
await page.evaluate(() => {
localStorage.setItem('auth-token', '...')
})
// Auth guard already detected unauthenticated and redirected

// ✅ Correct: Inject before page load
await page.addInitScript(() => {
localStorage.setItem('auth-token', '...')
})
await page.goto('/dashboard') // Auth guard detects token on load

addInitScript executes:

  1. Before page DOM parsing
  2. Before React/Vue framework initialization
  3. Before auth guard checks

Fixture Lifecycle

test('dashboard', async ({ authenticatedPage }) => {})

base.extend<{ authenticatedPage }>()

authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page) // 1. Setup auth
await use(page) // 2. Run test
} // 3. Auto cleanup

Complete Configuration

// playwright.config.ts
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // Serial execution to avoid state pollution
workers: 1,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})

Extension: Combined with API Mock

export const test = base.extend<{
authenticatedPage: Page
}>({
authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page)
await setupMockApi(page) // Also mock API
await use(page)
},
})

Key Principles

  1. Fixture over beforeEach — Automatic reuse, cleaner code
  2. addInitScript avoids race conditions — Inject token before auth guard checks
  3. Isolate test data — Don't mix mock users/tokens with production
  4. Serial execution prevents pollutionworkers: 1 or fullyParallel: false

Interested in similar solutions? Contact us

Bypass Supabase Auth for Playwright E2E Testing Without Login

· 4 min read

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: 'test@example.com',
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

  1. Use special prefix for test mode keys: test-auth-* won't appear in production
  2. Check before initialize: Check localStorage first, then proceed with Supabase Auth
  3. Skip auth listener: No need to listen for auth state changes in test mode
  4. Change loading default to false: Let the hook explicitly control loading state

Interested in similar solutions? Contact us