Part 5.5 — End-to-End Testing with Playwright: Real User Flows
Component and integration tests tell you pieces work.
End-to-end tests tell you the system works.
Playwright runs a real browser, loads your React app, talks to your NestJS REST API, and exercises flows exactly as users do. When an E2E test passes, you know your frontend, backend, database, auth, and routing are aligned.
This post shows how to write high-value E2E tests that protect real business flows—without turning your test suite into a maintenance nightmare.
1. What E2E Tests Are (and Are Not)
E2E tests are for:
critical user journeys
auth and permissions
multi-page flows
regressions that escaped before
They are not for:
edge-case UI states
exhaustive form validation
pixel-perfect assertions
replacing unit tests
Keep E2E tests few, focused, and meaningful.
2. Install and Initialize Playwright
From your React project:
pnpm add -D @playwright/test
pnpm exec playwright installInitialize config:
pnpm exec playwright initThis creates:
playwright.config.ts
tests/3. Configure Playwright for Your Stack
Edit playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
baseURL: 'http://localhost:5173',
headless: true,
},
webServer: {
command: 'pnpm dev',
port: 5173,
reuseExistingServer: !process.env.CI,
},
});This ensures:
React app is running
Playwright waits for it
CI behaves like local
Your backend should already be running (or started in CI).
4. Test the App Loads
Create tests/smoke.spec.ts:
import { test, expect } from '@playwright/test';
test('homepage loads', async ({ page }) => {
await page.goto('/');
await expect(page.getByText(/welcome/i)).toBeVisible();
});This simple test catches broken builds instantly.
5. Testing a Real Auth Flow
Assume:
/loginpageemail + password
JWT stored in memory or localStorage
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'alice@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(/welcome, alice/i)).toBeVisible();
});This test validates:
routing
form handling
backend auth
token handling
protected route access
One test. Huge coverage.
6. Creating Data via the UI (CRUD Flow)
test('user creates a post', async ({ page }) => {
await page.goto('/posts/new');
await page.fill('input[name="title"]', 'My First Post');
await page.fill('textarea[name="body"]', 'Hello world');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/posts');
await expect(page.getByText('My First Post')).toBeVisible();
});This covers:
form submission
API POST
DB write
redirect
list refresh
Exactly how users experience it.
7. Avoid UI Coupling with Roles & Labels
Prefer:
page.getByRole('button', { name: /submit/i });Over:
page.locator('.btn-primary');This makes tests:
resilient to CSS changes
aligned with accessibility
readable
Accessibility-friendly apps are easier to test.
8. Handling Auth Setup Faster (Optional)
Logging in via UI every test is slow.
Use a setup helper:
test.beforeEach(async ({ page }) => {
await page.request.post('/api/test/login', {
data: { email: 'alice@example.com' },
});
});This requires a test-only backend endpoint guarded by NODE_ENV === 'test'.
Trade-off:
⚡ faster tests
❌ less realistic
Use sparingly.
9. Isolate Test Data
Your E2E tests should use:
a dedicated test database
reset between runs
known seed data
Never run E2E tests against production or shared staging DBs.
10. Test Authorization Boundaries
Example:
test('non-owner cannot delete post', async ({ page }) => {
await page.goto('/posts/1');
await expect(
page.getByRole('button', { name: /delete/i }),
).toBeHidden();
});This locks in permission logic at the UI level.
11. Capture Screenshots on Failure
Playwright does this automatically.
In CI, this gives you:
screenshots
videos
traces
Which drastically reduces debugging time.
12. Keep E2E Tests Deterministic
Rules:
avoid random data unless seeded
avoid time-based assertions
avoid real emails / payments
avoid flaky selectors
If a test flakes once, fix it immediately.
13. How Many E2E Tests Is Enough?
A good baseline:
1 smoke test
1 auth flow
1 CRUD flow per core entity
1 permission boundary test
That’s usually under 15 tests—and extremely effective.
14. Summary
You now know how to:
set up Playwright for React apps
write real browser-based tests
validate auth, CRUD, and navigation
keep E2E tests focused and valuable
avoid common flakiness traps
In Part 5.6, you’ll wire frontend tests into CI, manage flakiness, and keep your test suite healthy as your app grows.
Related
Part 6.6 — Async State, Side Effects & Server State Boundaries
Most React state bugs come from mixing UI state with server state. This post teaches you how to draw a hard line between them—and why that line matters.
Part 6.5 — Introducing Redux Toolkit the Right Way
Redux isn’t the default anymore—but when your app needs it, Redux Toolkit is the cleanest, safest way to introduce global state at scale.
Part 6.4 — Combining Context + useReducer for App-Level State
Context provides access. Reducers provide structure. Together, they form a powerful, lightweight architecture for managing app-level state in React.
Comments