</>

Technology

Playwright

Difficulty

Intermediate

Interview Question

How do you test a Single Page Application (SPA) with client-side routing in Playwright?

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'));
});

Follow AutomateQA

Related Topics