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"] } },
],
});
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.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.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.