End-to-End Testing — Critical Flows with Playwright

Playwright is Microsoft’s end-to-end testing framework that drives real browsers (Chromium, Firefox, WebKit) and interacts with web applications exactly as a user would — clicking, typing, navigating. Unlike component tests that mock the API, Playwright tests run against the full application: the React frontend served by Vite (or a production build), making real HTTP calls to the FastAPI backend connected to a test database. This finds integration problems that no other layer catches.

Playwright Setup

npm install -D @playwright/test
npx playwright install chromium   # install browser binaries
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
    testDir:     "./e2e",
    timeout:     30_000,
    retries:     process.env.CI ? 2 : 0,   // retry flaky tests in CI
    workers:     process.env.CI ? 1 : undefined,
    reporter:    [["html"], ["github"]],

    use: {
        baseURL:    "http://localhost:5173",
        trace:      "on-first-retry",   // record trace for debugging
        screenshot: "only-on-failure",
    },

    // Start the Vite dev server and FastAPI before tests
    webServer: [
        {
            command: "npm run dev",
            url:     "http://localhost:5173",
            reuseExistingServer: !process.env.CI,
        },
        {
            command: "uvicorn app.main:app --port 8000 --env-file .env.test",
            url:     "http://localhost:8000/api/health",
            reuseExistingServer: !process.env.CI,
        },
    ],

    projects: [
        { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    ],
});
Note: Playwright’s webServer config starts the application before running tests and shuts it down after. The reuseExistingServer: !process.env.CI option means locally (where CI is not set), Playwright reuses an already-running dev server — so you do not restart Vite on every test run. In CI, it always starts a fresh server for reproducibility. The FastAPI server uses a .env.test file with a separate test database URL so tests do not touch the development database.
Tip: Use Playwright’s storageState to save and reuse authenticated browser sessions across tests. Log in once in a global setup file, save the cookies and localStorage to a JSON file, and load that state in every test fixture. This avoids logging in before every test (which is slow and noisy): test.use({ storageState: "playwright/.auth/user.json" }). Tests that require specific users (admin vs regular user) have separate storage state files created in global setup.
Warning: End-to-end tests are inherently slower and more prone to flakiness (intermittent failures from timing issues) than unit or component tests. Use explicit waits: await page.waitForURL("/dashboard") instead of await page.waitForTimeout(1000). Wait for specific elements or network conditions, never for arbitrary timeouts. The retries: 2 config in CI automatically retries flaky tests once before failing the CI run.

E2E Test — Login and Create Post

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
    test("user can register, login, and see dashboard", async ({ page }) => {
        const email    = `test-${Date.now()}@example.com`;
        const password = "Password1";

        // ── Register ──────────────────────────────────────────────────────────
        await page.goto("/register");
        await page.getByLabel("Name").fill("Test User");
        await page.getByLabel("Email").fill(email);
        await page.getByLabel(/^Password$/).fill(password);
        await page.getByLabel("Confirm Password").fill(password);
        await page.getByRole("button", { name: "Sign Up" }).click();

        // Should redirect to dashboard after registration
        await page.waitForURL("/dashboard");
        await expect(page.getByText("Test User")).toBeVisible();

        // ── Logout ────────────────────────────────────────────────────────────
        await page.getByRole("button", { name: "Sign Out" }).click();
        await page.waitForURL("/");

        // ── Login ─────────────────────────────────────────────────────────────
        await page.goto("/login");
        await page.getByLabel("Email").fill(email);
        await page.getByLabel("Password").fill(password);
        await page.getByRole("button", { name: "Sign In" }).click();

        await page.waitForURL("/dashboard");
        await expect(page.getByText("Test User")).toBeVisible();
    });

    test("shows error on wrong password", async ({ page }) => {
        await page.goto("/login");
        await page.getByLabel("Email").fill("test@example.com");
        await page.getByLabel("Password").fill("wrongpassword");
        await page.getByRole("button", { name: "Sign In" }).click();

        await expect(page.getByText("Invalid email or password")).toBeVisible();
    });
});

// e2e/posts.spec.ts
test.describe("Post creation", () => {
    test.use({ storageState: "playwright/.auth/user.json" });

    test("user can create a post and see it in the feed", async ({ page }) => {
        await page.goto("/posts/new");
        await page.getByLabel("Title").fill("My E2E Test Post");
        await page.getByLabel("Body").fill("This post was created by a Playwright test.");
        await page.getByRole("button", { name: "Publish Post" }).click();

        // Should navigate to the new post's detail page
        await expect(page).toHaveURL(/\/posts\/\d+/);
        await expect(page.getByRole("heading", { name: "My E2E Test Post" })).toBeVisible();

        // Navigate to home and verify post appears in feed
        await page.goto("/");
        await expect(page.getByText("My E2E Test Post")).toBeVisible();
    });
});

Authenticated Session Fixture

// playwright/global-setup.ts — create auth session once
import { chromium, FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
    const browser = await chromium.launch();
    const page    = await browser.newPage();

    await page.goto("http://localhost:5173/login");
    await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!);
    await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
    await page.getByRole("button", { name: "Sign In" }).click();
    await page.waitForURL("**/dashboard");

    // Save auth state (cookies + localStorage) for reuse
    await page.context().storageState({ path: "playwright/.auth/user.json" });
    await browser.close();
}

export default globalSetup;

Common Mistakes

Mistake 1 — Arbitrary timeouts instead of explicit waits

❌ Wrong — flaky on slow CI machines:

await page.waitForTimeout(2000);   // hope 2s is enough...

✅ Correct — wait for a specific condition:

await page.waitForURL("/dashboard");   // ✓ deterministic

Mistake 2 — Not isolating test data (tests interfere with each other)

❌ Wrong — one test creates a post titled “Test” and another verifies no posts titled “Test” exist.

✅ Correct — use unique data per test: const title = `Post ${Date.now()}`. Use database cleanup in global teardown.

🧠 Test Yourself

Why should E2E tests use page.waitForURL("/dashboard") rather than page.waitForTimeout(2000) after clicking the login button?