E2E Tests: The Full Stack Check
Source: Dev.to
Part of The Coercion Saga — making AI write quality code.
Backend tests pass. Frontend tests pass. The contract is validated. Production breaks.
The API works. The components work. Types match. But the login flow? The cookie doesn’t persist. CORS blocks the request. The redirect fails. Nobody tested the pieces together.
E2E tests run a real browser against a real backend with a real database. No mocks. No fakes. What users experience.
Why E2E After Unit Tests
Unit tests: fast, isolated, hundreds of them. They check pieces.
E2E tests: slow, integrated, dozens of them. They check flows.
E2E catches what unit tests miss:
- CORS configuration errors
- Cookie and session handling (
SameSite,Secure,HttpOnlyflags) - Real database constraints (foreign keys, unique violations, cascade deletes)
- Browser‑specific quirks (Safari’s third‑party cookie blocking)
- Multi‑step user journeys with actual state persistence
Don’t replace unit tests with E2E. Add E2E for the critical paths—login, registration, checkout. The flows that cost money when they break.
Playwright
Playwright drives real browsers: Chrome, Firefox, Safari. Same test, multiple engines. Not headless‑browser simulations—actual browser binaries.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // No .only() in CI
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure', // The debugging killer feature
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// Add these when you're serious about cross‑browser
// { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
// { name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
// Wait for both backend and frontend
webServer: [
{
command: 'cd ../backend && uv run uvicorn app.main:app --port 8000',
url: 'http://localhost:8000/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
],
});
trace: 'on-first-retry' is the debugging multiplier. When a test fails you get a timeline: every network request, every DOM change, every click. Click through it. No more “works on my machine.”
video: 'retain-on-failure' is the evidence. The test says “button not found.” The video shows the button was there but covered by a modal. Mystery solved.
Test Structure: User Stories, Not Unit Tests
Test user flows, not isolated actions. E2E tests are expensive—make them count.
import { test, expect } from '@playwright/test';
test('complete auth flow: register, logout, login, forgot password', async ({
page,
context,
}) => {
const email = `test-${Date.now()}@example.com`;
// === Registration ===
await page.goto('/register');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: /register/i }).click();
// Should land on dashboard with session
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(email)).toBeVisible();
// Check cookie was set correctly
const cookies = await context.cookies();
const sessionCookie = cookies.find((c) => c.name === 'session');
expect(sessionCookie).toBeDefined();
expect(sessionCookie!.httpOnly).toBe(true); // Security check
// === Logout ===
await page.getByTestId('user-menu').click();
await page.getByRole('menuitem', { name: /logout/i }).click();
await expect(page).toHaveURL('/');
// Session cookie should be gone
const postLogoutCookies = await context.cookies();
expect(postLogoutCookies.find((c) => c.name === 'session')).toBeUndefined();
// === Login ===
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: /login/i }).click();
await expect(page).toHaveURL('/dashboard');
});
That’s three tests in one, but they represent the same user journey. Testing the steps separately adds setup/teardown overhead and flakiness. Test the flow, not the individual actions.
The cookie assertions enforce security in the tests. If someone accidentally removes httpOnly, the test fails—no “we’ll catch it in code review” excuse.
Fixtures: Don’t Repeat Setup
Every test creating a user is slow and noisy. Fixtures let you do it once.
// e2e/fixtures.ts
import { test as base, Page } from '@playwright/test';
type TestFixtures = {
authenticatedPage: Page;
testUser: { email: string; password: string };
};
export const test = base.extend({
testUser: async ({}, use) => {
const email = `test-${Date.now()}@example.com`;
const password = 'TestPass123!';
// Create user via API (faster than UI)
await fetch('http://localhost:8000/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
await use({ email, password });
// Cleanup after test (optional but nice)
// await deleteUser(email);
},
authenticatedPage: async ({ page, testUser }, use) => {
// Login via API, set cookie
const response = await page.request.post('/api/auth/login', {
data: testUser,
});
const { token } = await response.json();
await page.context().addCookies([
{
name: 'session',
value: token,
domain: 'localhost',
path: '/',
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
]);
await use(page);
},
});
Now any test that needs an authenticated user can simply import test from e2e/fixtures.ts and start interacting with authenticatedPage right away, keeping the suite fast and maintainable.
```js
domain: 'localhost',
path: '/',
}]);
await use(page);
},
});
Creating a Logged‑In Page Fixture
import { test } from './fixtures';
test('user can create item', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/items/new');
// Already logged in, no setup needed
});
Takeaway:
API setup is ~10× faster than clicking through the UI. Use E2E tests for UI verification, not for creating test data.
The Flakiness Problem
E2E tests are flaky because of network delays, animations, and race conditions. Accept that they’ll flake and build defenses.
test('handles slow network gracefully', async ({ page }) => {
// Simulate a 3G connection
await page.route('**/api/**', async route => {
await new Promise(r => setTimeout(r, 2000)); // 2 s delay
await route.continue();
});
await page.goto('/dashboard');
// Loading state should appear
await expect(page.getByTestId('loading-spinner')).toBeVisible();
// Then data loads
await expect(page.getByText('Welcome')).toBeVisible({ timeout: 10000 });
});
That test catches two bugs that are invisible on fast networks:
- Loading state never shows
- Loading state never disappears
Waiting for the Right Thing
// Wait for network to be idle before asserting
await page.waitForLoadState('networkidle');
// Or wait for a specific request
await Promise.all([
page.waitForResponse(
resp => resp.url().includes('/api/items') && resp.status() === 200
),
page.getByRole('button', { name: /save/i }).click(),
]);
waitForResponse is far better than waitForTimeout—it waits for the exact condition you care about.
Error Paths: The Tests Everyone Skips
Happy paths are easy; error paths reveal hidden bugs.
test('shows helpful error when backend is down', async ({ page }) => {
await page.route('**/api/**', route => route.abort('connectionfailed'));
await page.goto('/dashboard');
await expect(page.getByText(/unable to connect/i)).toBeVisible();
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
});
test('handles session expiry gracefully', async ({ page, context }) => {
// Start logged in
await context.addCookies([{
name: 'session',
value: 'expired-token',
domain: 'localhost',
path: '/',
}]);
// API returns 401
await page.route('**/api/users/me', route =>
route.fulfill({ status: 401, json: { detail: 'Session expired' } })
);
await page.goto('/dashboard');
// Should redirect to login with a message
await expect(page).toHaveURL('/login');
await expect(page.getByText(/session expired/i)).toBeVisible();
});
Session expiry is a user‑experience issue—test not just the redirect, but also the helpful message.
The CI Gate
e2e:
stage: e2e
image: mcr.microsoft.com/playwright:v1.48.0-jammy
services:
- name: postgres:16
alias: db
variables:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/test
POSTGRES_HOST_AUTH_METHOD: trust
BASE_URL: http://localhost:5173
BACKEND_URL: http://localhost:8000
before_script:
- pip install uv
# Start backend
- cd backend
- uv sync --frozen
- uv run alembic upgrade head
- uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 &
- cd ..
# Start frontend
- cd frontend
- npm ci
- npm run dev -- --host &
# Wait for services
- npx wait-on http://localhost:8000/health http://localhost:5173 --timeout 60000
script:
- cd frontend
- npx playwright test
artifacts:
when: always
paths:
- frontend/playwright-report/
- frontend/test-results/
expire_in: 1 week
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- Use
wait-oninstead of a fixedsleep. It polls until the services respond, eliminating “is 30 seconds enough?” guesses. when: alwayson artifacts is non‑negotiable—failed tests need traces, screenshots, and videos for debugging.
Copy, paste, adapt. It works.
The Point
- Unit tests verify isolated pieces.
- E2E tests verify the whole system.
If both pass, the code works. If one fails, you know where to look.
E2E tests are slow. Run them only on critical paths: login, registration, checkout, password reset—flows that cost money when they break.
The Playwright trace is a super‑power. When a test fails in CI, download the trace, step through every action, and see exactly what the browser saw. Fixes that once took hours now take minutes.
- Playwright handles the browser.
- CI handles the infrastructure.
- You write the test logic.
Everything else is automated.
Next up: [Performance Tests] – coming soon – It works. But is it fast enough?