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.

This gives us:
Speed — no database queries, no network latency
Determinism — same data every run, no flaky tests from shared state
Isolation — frontend bugs can't be masked by backend issues (or vice versa)
Portability — works on any machine, including CI runners with no Docker
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 chromiumThe 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:
Route order matters. Playwright matches routes in registration order.
/posts/search*must come before/posts?*, or search requests get caught by the list handler.Match your API's envelope format. Tirraflow's API wraps lists in
{ data, page, page_size, total_items, total_pages }. Axios then wraps that inresponse.data. So when the frontend doesresponse.data.data, your mock needs the inner.datakey.Keycloak can just fail. The
AuthProvidercatches 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
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
Mock shape mismatches are the #1 source of false greens.
Playwright's
page.route()is ordered.Don't test Keycloak in frontend E2E.
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 viewportsEvery 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
79 tests at the time of writing (103 today, same approach) covering all public pages, navigation, dark mode, SEO, accessibility, and responsive layout
Zero backend dependencies — runs anywhere Node.js runs
Under 30 seconds locally, under a minute in CI
HTML report with screenshots on failure — uploaded as a CI artifact
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 |
|---|---|
The stack, the architecture, and what I learned shipping it | |
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 |
From push to production, and how the pipeline learned to roll back | |
How it got in, and the six layers that keep it out |