Cypress can actually test client-side routing in SPAs more reliably than you might think, by directly interacting with the browser’s history API.

Let’s see how. Imagine a simple React app with react-router-dom managing navigation.

// src/App.js
import React from 'react';
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom';

function Home() {
  return <h2>Home</h2>;
}

function About() {
  return <h2>About</h2>;
}

function App() {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li><Link to="/">Home</Link></li>
            <li><Link to="/about">About</Link></li>
          </ul>
        </nav>
        <Switch>
          <Route path="/about"><About /></Route>
          <Route path="/"><Home /></Route>
        </Switch>
      </div>
    </Router>
  );
}

export default App;

Here’s a Cypress test that verifies navigating from the home page to the about page.

// cypress/e2e/routing.cy.js
describe('Client-Side Routing', () => {
  beforeEach(() => {
    // Visit the root URL, which loads our SPA
    cy.visit('/');
  });

  it('should navigate to the about page', () => {
    // Check that we are initially on the home page
    cy.url().should('include', '/');
    cy.contains('h2', 'Home').should('be.visible');

    // Click the "About" link
    cy.get('nav a').contains('About').click();

    // Verify that the URL has changed to /about
    cy.url().should('include', '/about');

    // Verify that the About component is now visible
    cy.contains('h2', 'About').should('be.visible');
  });

  it('should navigate back to the home page', () => {
    // Navigate to the about page first
    cy.get('nav a').contains('About').click();
    cy.url().should('include', '/about');
    cy.contains('h2', 'About').should('be.visible');

    // Use Cypress's built-in functionality to go back in browser history
    cy.go('back');

    // Verify that the URL is back to the home page
    cy.url().should('include', '/');
    cy.contains('h2', 'Home').should('be.visible');
  });

  it('should handle direct URL access', () => {
    // Visit the about page directly
    cy.visit('/about');

    // Verify that the About component is visible
    cy.url().should('include', '/about');
    cy.contains('h2', 'About').should('be.visible');

    // Verify that the Home component is NOT visible
    cy.contains('h2', 'Home').should('not.exist');
  });
});

The core problem Cypress solves here is simulating user interaction within the SPA’s routing context. Unlike traditional page reloads, client-side routing relies on JavaScript manipulating the browser’s History API (pushState, replaceState, popstate events). Cypress commands like cy.visit(), cy.get(...).click(), and cy.go() all operate within the browser’s DOM and history, so they naturally trigger these client-side routing changes.

When you cy.visit('/'), Cypress loads your index.html and your SPA’s JavaScript takes over. The BrowserRouter (or HashRouter) initializes and listens for navigation events. Clicking a Link component in your SPA is equivalent to a user clicking a standard <a> tag, but react-router-dom intercepts this click, prevents the default browser navigation, and instead uses history.pushState() to update the URL and render the correct component. Cypress sees this URL change via cy.url() and the DOM changes via cy.contains().

The cy.go('back') command is particularly powerful. It directly calls the browser’s history.back() method, which fires a popstate event. Your SPA’s router listens for popstate events to handle browser navigation buttons (back/forward). Cypress detects the URL change and DOM update that follows.

When you cy.visit('/about'), you’re telling Cypress to load that specific URL. If your SPA’s routing is correctly configured to handle direct deep links, the router will match /about on initial load and render the appropriate component, just as if the user had landed on that page directly.

The key is that Cypress executes inside the browser. It’s not just inspecting network requests; it’s interacting with the rendered DOM and the browser’s native history. This means it tests your SPA’s routing logic exactly as a user would experience it.

A common pitfall is assuming that cy.visit('/about') will always work without proper server configuration if you’re not serving your SPA correctly. For SPAs, your web server needs to be configured to serve your index.html for any route that your client-side router handles. If the server tries to find a file at /about and doesn’t find one, it will return a 404, and Cypress will see that 404, not your SPA’s routing. Ensure your server (like webpack-dev-server, Nginx, or Apache) is set up to fallback to index.html.

The next thing you’ll likely want to tackle is testing nested routes and dynamic route parameters, which involves asserting on URLs with variables and checking for elements that are conditionally rendered based on those parameters.

Want structured learning?

Take the full Cypress course →