The most surprising thing about testing Single Page Applications (SPAs) with Playwright is that you often don’t need to do anything special for client-side navigation.
Let’s see Playwright in action with a simple React SPA. Imagine this App.js:
import React, { useState } from 'react';
import './App.css';
function App() {
const [page, setPage] = useState('home');
return (
<div className="App">
<header className="App-header">
<nav>
<button onClick={() => setPage('home')}>Home</button>
<button onClick={() => setPage('about')}>About</button>
<button onClick={() => setPage('contact')}>Contact</button>
</nav>
</header>
<main>
{page === 'home' && <h1>Welcome Home!</h1>}
{page === 'about' && <h1>About Us</h1>}
{page === 'contact' && <h1>Get in Touch</h1>}
</main>
</div>
);
}
export default App;
And here’s a Playwright test that interacts with it:
import { test, expect } from '@playwright/test';
test('should navigate between pages', async ({ page }) => {
await page.goto('http://localhost:3000'); // Assuming your SPA runs on port 3000
// Initial state
await expect(page.getByText('Welcome Home!')).toBeVisible();
// Navigate to About
await page.getByRole('button', { name: 'About' }).click();
await expect(page.getByText('About Us')).toBeVisible();
await expect(page.getByText('Welcome Home!')).not.toBeVisible();
// Navigate to Contact
await page.getByRole('button', { name: 'Contact' }).click();
await expect(page.getByText('Get in Touch')).toBeVisible();
await expect(page.getByText('About Us')).not.toBeVisible();
// Navigate back to Home
await page.getByRole('button', { name: 'Home' }).click();
await expect(page.getByText('Welcome Home!')).toBeVisible();
await expect(page.getByText('Get in Touch')).not.toBeVisible();
});
Playwright doesn’t see "client-side navigation" as a special event. It sees DOM changes, element interactions, and network requests. When you click a button that triggers a state change in your SPA (like setPage('about')), your JavaScript code manipulates the DOM. Playwright is already listening for DOM changes and waiting for elements to appear or disappear. The await expect(...).toBeVisible() assertions are what drive the test forward, ensuring the application has settled into its new state before the next action.
The core problem SPAs solve is avoiding full page reloads. Instead of the browser requesting a new HTML document from the server for every "page" change, the initial HTML loads a JavaScript bundle. This bundle then intercepts user interactions, updates the DOM directly (or fetches data and then updates the DOM), and often manipulates the browser’s history API (pushState or replaceState) to update the URL. This makes the application feel faster and more responsive.
Playwright’s strength here is its ability to interact with the DOM as it’s being manipulated. It doesn’t need to understand the intricacies of React’s useState or Vue’s ref. It just sees that an element that wasn’t visible is now visible, or that a button click occurred. The page.goto() command loads the initial HTML and JavaScript. From there, Playwright’s API (click, fill, getByRole, etc.) is designed to interact with the rendered DOM. When your SPA’s JavaScript updates the DOM, Playwright’s built-in waiting mechanisms (like waiting for an element to be visible or attached) handle the asynchronous nature of these updates.
The page.waitForNavigation() method, often used for traditional page loads, is usually not necessary for purely client-side routing. If your SPA uses pushState and only renders new components without a full server roundtrip, waitForNavigation might time out or behave unexpectedly because no actual navigation event (in the browser’s traditional sense) occurred. Instead, you rely on asserting the presence or absence of specific DOM elements that indicate the new "page" has rendered.
The underlying browser automation protocol Playwright uses monitors DOM mutations and network activity. When you click an element, Playwright sends the click event. Your SPA’s JavaScript handles that event, updates the DOM, and potentially makes AJAX calls. Playwright then observes these changes. Assertions like expect(locator).toBeVisible() don’t just check the current state; they implicitly wait for the condition to be met. If the element isn’t immediately visible after a click, Playwright will wait for a default timeout (usually 30 seconds) for it to appear. This is why simple DOM assertions are often sufficient for client-side navigation.
A common pitfall is expecting page.waitForNavigation() to work for client-side routes. If you are using a router like React Router or Vue Router and they solely rely on the History API without full page reloads, waitForNavigation will not trigger. The browser doesn’t perceive a navigation event. Instead, you should use page.waitForURL() if the URL is expected to change via pushState, or more commonly, assert the visibility of elements that signify the new route’s content.
If your SPA does have a hybrid approach where some actions trigger full page reloads and others are client-side, Playwright’s page.goto() and page.click() will naturally handle both. The key is to write assertions that are specific to the outcome of the navigation (i.e., what content is now visible) rather than the mechanism of navigation itself.
The next challenge is handling dynamic content loading within your SPA, which often involves waiting for network requests to complete before asserting visibility.