Building Tirraflow Part 3: E2E Testing a React SPA with Playwright

Testing a single-page app end-to-end usually means spinning up the full stack — database, API, auth server. For Tirraflow, that's PostgreSQL, a Go API, and Keycloak. That's a lot of infrastructure...

Testing a single-page app end-to-end usually means spinning up the full stack — database, API, auth server. For Tirraflow, that's PostgreSQL, a Go API, and Keycloak. That's a lot of infrastructure for checking if a button works.

So I took a different approach: mock the API at the network level, let auth fail gracefully, and test the entire frontend in isolation. 79 tests, zero backend dependencies, runs in CI in under a minute.


The Strategy

Instead of running a real backend, Playwright intercepts every HTTP request the app makes and returns deterministic JSON. The app doesn't know it's talking to mocks — it just renders what it gets.

Test strategy: Playwright intercepts every API request and answers with deterministic mock JSON

This gives us:

The tradeoff: you're not testing the real API contract. That's what integration tests on the backend are for. E2E tests on the frontend should answer: does the UI work correctly given valid API responses?


Setting Up Playwright

npm install -D @playwright/test
npx playwright install chromium

The config is straightforward. One trick: detect --headed mode and adjust behavior so tests run in a single window for debugging:

// playwright.config.js
import { defineConfig } from '@playwright/test';

const isHeaded = process.argv.includes('--headed');

export default defineConfig({
  testDir: './e2e',
  fullyParallel: !isHeaded,
  workers: (process.env.CI || isHeaded) ? 1 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    ...(isHeaded && { launchOptions: { slowMo: 50 } }),
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

In headless mode (default), tests run in parallel across workers. In headed mode, they run sequentially with a 50ms delay so you can actually watch what's happening. The webServer block auto-starts Vite if it's not already running.


The Mock Layer

This is the core of the approach. A single fixtures.js file defines all mock data and a setupMocks() function that wires up API interception.

Mock Data

// e2e/fixtures.js
export const mockPosts = [
  {
    id: 1,
    title: 'Getting Started with React 19',
    slug: 'getting-started-with-react-19',
    content: '<h2>Introduction</h2><p>React 19 brings exciting new features...</p>',
    excerpt: 'React 19 brings exciting new features...',
    author: 'Jane Doe',
    category: { id: 1, name: 'Technology', color: '#3b82f6' },
    tags: [{ id: 1, name: 'React', color: '#61dafb' }],
    cover_image: '/uploads/cover1.jpg',
    view_count: 150,
    comment_count: 3,
    created_at: '2026-03-10T10:00:00Z',
  },
  // ... more posts
];

export const mockCategories = [
  { id: 1, name: 'Technology', description: 'Tech articles', color: '#3b82f6', slug: 'technology' },
  { id: 2, name: 'Design', description: 'UI/UX and graphic design', color: '#ec4899', slug: 'design' },
];

The shape of this data must match what your API actually returns. Field names, nesting, envelopes — get any of these wrong and your tests will pass against mocks but fail against the real API.

Route Interception

export async function setupMocks(page) {
  // Block Keycloak — let AuthProvider handle the failure
  await page.route('**/realms/**', route =>
    route.fulfill({ status: 200, body: '{}', contentType: 'application/json' })
  );

  // Posts list — paginated envelope
  await page.route('**/v1/posts?*', (route) => {
    const url = new URL(route.request().url());
    const page = parseInt(url.searchParams.get('page') || '1');
    const category = url.searchParams.get('category');

    let filtered = [...mockPosts];
    if (category) filtered = filtered.filter(p => String(p.category_id) === category);

    route.fulfill({
      json: {
        data: filtered.slice((page - 1) * 10, page * 10),
        page,
        page_size: 10,
        total_items: filtered.length,
        total_pages: Math.ceil(filtered.length / 10),
      },
    });
  });

  // Single post by slug
  await page.route('**/v1/posts/by-slug/*', (route) => {
    const slug = route.request().url().split('/by-slug/')[1]?.split('?')[0];
    const post = mockPosts.find(p => p.slug === slug);
    route.fulfill(post ? { json: post } : { status: 404, json: { error: 'not found' } });
  });

  // Comments — note the { data: [...] } wrapper
  await page.route('**/v1/posts/*/comments', (route) => {
    route.fulfill({ json: { data: mockComments } });
  });

  // ... categories, tags, notifications, etc.
}

Three things to watch for:

  1. Route order matters. Playwright matches routes in registration order. /posts/search* must come before /posts?*, or search requests get caught by the list handler.

  2. Match your API's envelope format. Tirraflow's API wraps lists in { data, page, page_size, total_items, total_pages }. Axios then wraps that in response.data. So when the frontend does response.data.data, your mock needs the inner .data key.

  3. Keycloak can just fail. The AuthProvider catches init errors and renders as unauthenticated. No need to mock the full OIDC flow for public pages.


Writing Tests

Every test file follows the same pattern: import shared fixtures, set up mocks, navigate, assert.

// e2e/homepage.spec.js
import { test, expect } from './shared-page.js';
import { setupMocks, mockPosts } from './fixtures.js';

test.describe('Homepage / Posts List', () => {
  test.beforeEach(async ({ page }) => {
    await setupMocks(page);
    await page.goto('/');
  });

  test('renders post cards with titles', async ({ page }) => {
    for (const post of mockPosts) {
      await expect(page.getByRole('heading', { name: post.title })).toBeVisible();
    }
  });

  test('clicking a post navigates to detail page', async ({ page }) => {
    await page.getByRole('heading', { name: mockPosts[0].title }).click();
    await expect(page).toHaveURL(`/posts/${mockPosts[0].slug}`);
  });

  test('filtering by category shows correct posts', async ({ page }) => {
    await page.goto('/posts?category=3');
    await expect(page.getByRole('heading', { name: 'Docker Best Practices' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Getting Started with React 19' })).not.toBeVisible();
  });
});

Notice: tests reference mockPosts data directly instead of hardcoding strings. If you rename a mock post title, every test using it updates automatically.

What We Test

Suite

Tests

What it covers

Homepage

8

Post list, search, category/tag filters

Post Detail

10

Content rendering, comments, related posts

Categories

7

List page, descriptions, auth gating

Tags

5

Tag list, auth gating

Navigation

11

Navbar, footer, cross-page links

Dark Mode

4

Toggle, persistence, color changes

404 Page

4

Not found states

Accessibility

11

Landmarks, skip link, headings, keyboard nav

SEO

10

Meta tags, Open Graph, JSON-LD

Responsive

9

Mobile, tablet, desktop viewports

Total

79


Handling Auth-Gated Features

For pages that require authentication (creating posts, managing categories), the tests verify the gating works — not the feature behind it:

test('create button is hidden for unauthenticated users', async ({ page }) => {
  await setupMocks(page);
  await page.goto('/categories');
  await expect(page.getByRole('link', { name: /create/i })).not.toBeVisible();
});

Since Keycloak init fails in tests, the user is always unauthenticated. To test authenticated flows, you'd extend the fixtures to mock a valid token response — but that's a separate concern from the public UI tests.


Dark Mode Testing

Testing dark mode sounds trivial, but CSS can be surprising. You need to verify actual computed styles, not just class presence:

test('dark mode changes background color', async ({ page }) => {
  await setupMocks(page);
  await page.goto('/');

  // Toggle dark mode
  await page.getByRole('button', { name: /dark mode|light mode|theme/i }).click();

  // Check computed background — walk up the DOM tree to find the first
  // non-transparent background
  const bg = await page.evaluate(() => {
    let el = document.body;
    while (el) {
      const bg = getComputedStyle(el).backgroundColor;
      if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
      el = el.firstElementChild;
    }
    return null;
  });

  expect(bg).toBeTruthy();
});

SEO Validation

E2E tests are perfect for catching SEO regressions — meta tags that vanish after a refactor, missing Open Graph images, broken structured data:

test('post detail page has correct OG tags', async ({ page }) => {
  await setupMocks(page);
  await page.goto(`/posts/${mockPosts[0].slug}`, { waitUntil: 'networkidle' });

  const ogTitle = await page.locator('meta[property="og:title"]').first().getAttribute('content');
  expect(ogTitle).toContain(mockPosts[0].title);

  const ogType = await page.getAttribute('meta[property="og:type"]', 'content');
  expect(ogType).toBe('article');
});

test('homepage has valid JSON-LD', async ({ page }) => {
  await setupMocks(page);
  await page.goto('/');

  const jsonLd = await page.$eval(
    'script[type="application/ld+json"]',
    el => JSON.parse(el.textContent)
  );
  expect(jsonLd['@type']).toBe('Blog');
});

Running in CI

The GitHub Actions workflow installs Playwright alongside the existing lint and unit test steps:

# .github/workflows/ci.yml
- name: Install Playwright browsers
  run: npx playwright install chromium --with-deps

- name: E2E tests
  run: npm run test:e2e
  env:
    VITE_API_URL: http://localhost:8080/v1
    VITE_KEYCLOAK_URL: http://localhost:8082
    VITE_KEYCLOAK_REALM: tirraflow_realm
    VITE_KEYCLOAK_CLIENT_ID: tirraflow_client_id

- name: Upload E2E report
  uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 14
CI job running the Playwright suite and uploading the HTML report as an artifact

The --with-deps flag installs system libraries (fonts, graphics) that Chromium needs on Ubuntu. The if: !cancelled() on artifact upload ensures you get the HTML report even when tests fail — that's when you need it most.

VITE env vars point to localhost addresses that never actually connect. The mocks intercept all requests before they reach the network. But Vite needs the env vars at build time to construct the Axios base URL.


Lessons Learned

  1. Mock shape mismatches are the #1 source of false greens.

  2. Playwright's page.route() is ordered.

  3. Don't test Keycloak in frontend E2E.

  4. Headed mode is invaluable for debugging.


File Structure

e2e/
├── fixtures.js           # Mock data + setupMocks()
├── shared-page.js        # Custom fixture for headed mode
├── homepage.spec.js      # Post list, search, filters
├── post-detail.spec.js   # Content, comments, related posts
├── categories.spec.js    # Category list, auth gating
├── tags.spec.js          # Tag list, auth gating
├── navigation.spec.js    # Navbar, footer, cross-page links
├── dark-mode.spec.js     # Theme toggle, persistence
├── not-found.spec.js     # 404 states
├── accessibility.spec.js # Landmarks, keyboard nav, headings
├── seo.spec.js           # Meta tags, OG, JSON-LD
└── responsive.spec.js    # Mobile, tablet, desktop viewports

Every spec imports from fixtures.js for data and shared-page.js for the test/expect functions. Adding a new test file means: create the spec, import the fixtures, call setupMocks(page) in beforeEach, write your assertions.


The Result

The key insight: for a frontend E2E suite, you don't need the real backend. You need realistic API responses. page.route() gives you exactly that — network-level interception that the app can't distinguish from a real server.


What's Next

Part 4: CI/CD Auto-Deploy with GitHub Actions — from push to production, and how the pipeline learned to roll back.


The Series

Building Tirraflow is a 5-part series on how the platform you are reading was built and run; every part is linked below.

Part

Topic

Part 1

The stack, the architecture, and what I learned shipping it

Part 2

Non-blocking auth for a SPA, and why Keycloak ended up same-origin

Part 3 (this post)

Testing the whole frontend with no backend at all

Part 4

From push to production, and how the pipeline learned to roll back

Part 5

How it got in, and the six layers that keep it out

Read on Tirraflow