Answer
Scenario: Testing SPAs with Client-Side Routing
SPAs (React, Vue, Angular) update the URL without a full page reload, requiring different testing strategies than traditional multi-page apps.
Key Challenge: No Full Page Reload
TypeScript
// BAD — waits for a reload that never happens in SPA
await page.goto('/products');
await page.getByRole('link', { name: 'About' }).click();
await page.waitForLoadState('load'); // May timeout — no reload happens
// GOOD — wait for URL change instead
await page.getByRole('link', { name: 'About' }).click();
await page.waitForURL('/about');
await expect(page.getByRole('heading')).toHaveText('About Us');
Navigate Between SPA Routes
TypeScript
import { test, expect } from '@playwright/test';
test('SPA navigation between routes', async ({ page }) => {
await page.goto('/');
// Click navigation link — React Router updates URL without reload
await page.getByRole('link', { name: 'Products' }).click();
// Wait for the URL to update
await page.waitForURL('/products');
// Verify new content loaded by React
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
await expect(page.locator('.product-card')).toHaveCount.greaterThan(0);
});
Handle Lazy-Loaded Routes (Code Splitting)
TypeScript
test('lazy-loaded route loads correctly', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Admin Dashboard' }).click();
// Lazy route may trigger a network request to load the JS chunk
await page.waitForURL('/admin');
await page.waitForLoadState('networkidle'); // Wait for chunk to download
await expect(page.getByRole('heading')).toHaveText('Admin Dashboard');
});
Test Back Button in SPA
TypeScript
test('browser back button works in SPA', async ({ page }) => {
await page.goto('/products');
await page.getByRole('link', { name: 'Laptop Pro' }).click();
await page.waitForURL('/products/laptop-pro');
await expect(page.getByRole('heading')).toHaveText('Laptop Pro');
// Go back
await page.goBack();
await page.waitForURL('/products');
await expect(page.getByRole('heading')).toHaveText('Products');
});
Test Deep Link (Direct URL Access)
TypeScript
test('direct URL access to SPA route', async ({ page }) => {
// Navigate directly to a deep URL (not via links)
await page.goto('/products/category/electronics');
// SPA router should handle this and render the correct component
await expect(page).toHaveURL('/products/category/electronics');
await expect(page.getByRole('heading')).toHaveText('Electronics');
await expect(page.locator('.product-card')).toBeVisible();
});
Handle Route Guards (Auth)
TypeScript
test('protected route redirects to login', async ({ page }) => {
// Try to access protected route without auth
await page.goto('/admin/dashboard');
// React Router guard should redirect to login
await page.waitForURL('/login');
await expect(page.getByText('Please log in to continue')).toBeVisible();
});
test('protected route accessible when logged in', async ({ page }) => {
// Login via storageState (already set up)
await page.goto('/admin/dashboard');
// No redirect — authorized user
await expect(page).toHaveURL('/admin/dashboard');
await expect(page.getByRole('heading')).toHaveText('Admin Dashboard');
});
Test Query Params and Filters in SPA URL
TypeScript
test('URL reflects applied filters in SPA', async ({ page }) => {
await page.goto('/products');
await page.getByLabel('Category').selectOption('Electronics');
await page.getByLabel('Price Range').selectOption('under-100');
// Verify URL updated with filter params (React Router updates history)
await page.waitForURL('**/products*category*Electronics*');
expect(page.url()).toContain('category=Electronics');
expect(page.url()).toContain('price=under-100');
// Verify results match filters
const categories = await page.locator('td.category').allTextContents();
categories.forEach(c => expect(c).toBe('Electronics'));
});
