What to E2E Test
Focus on critical user journeys and integration points:Authentication
Sign in, sign out, session management
Core Workflows
Create task, approve quote, assign subtask
Client Portal
Client sign-in, view tasks, submit tickets
Multi-User Flows
PM assigns task → IC completes → PM reviews
Test Structure
File Organization
Copy
e2e/
├── auth.spec.ts # Authentication flows
├── tasks.spec.ts # Task management
├── quotes.spec.ts # Quote workflows
├── client-portal.spec.ts # Client portal
├── permissions.spec.ts # Access control
├── global-setup.ts # Test data setup
└── seed-e2e-data.ts # Seed script
Basic Test Example
Copy
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("redirects to auth page when not logged in", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL("/auth");
});
test("shows sign in with Google button", async ({ page }) => {
await page.goto("/auth");
const signInButton = page.locator('button:has-text("Sign in with Google")');
await expect(signInButton).toBeVisible();
});
});
Playwright Features
Navigation
Copy
// Navigate to page
await page.goto("/tasks");
// Wait for navigation
await page.click('a[href="/tasks"]');
await page.waitForURL("/tasks");
// Go back/forward
await page.goBack();
await page.goForward();
Locators
Copy
// By text
page.locator('text=Create Task');
page.locator('button:has-text("Submit")');
// By role
page.getByRole("button", { name: "Create Task" });
page.getByRole("link", { name: "Tasks" });
// By test ID
page.locator('[data-testid="task-card"]');
// By CSS selector
page.locator('.task-card');
page.locator('#task-123');
// By placeholder
page.getByPlaceholder("Search tasks...");
// By label
page.getByLabel("Task title");
Interactions
Copy
// Click
await page.click('button:has-text("Create")');
await page.getByRole("button", { name: "Submit" }).click();
// Type
await page.fill('input[name="title"]', "New Task");
await page.type('input[name="title"]', "New Task");
// Select
await page.selectOption('select[name="priority"]', "high");
await page.selectOption('select[name="priority"]', { label: "High" });
// Check/uncheck
await page.check('input[type="checkbox"]');
await page.uncheck('input[type="checkbox"]');
// Upload file
await page.setInputFiles('input[type="file"]', "path/to/file.pdf");
Assertions
Copy
// Visibility
await expect(page.locator('text=Success')).toBeVisible();
await expect(page.locator('text=Error')).toBeHidden();
// Text content
await expect(page.locator('h1')).toHaveText("Dashboard");
await expect(page.locator('.status')).toContainText("Complete");
// URL
await expect(page).toHaveURL("/tasks");
await expect(page).toHaveURL(/\/tasks\/task-/);
// Count
await expect(page.locator('.task-card')).toHaveCount(3);
// Attribute
await expect(page.locator('button')).toHaveAttribute("disabled");
await expect(page.locator('input')).toHaveValue("Initial value");
Common Test Patterns
Authentication Flow
Copy
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("successful Google OAuth flow", async ({ page }) => {
await page.goto("/auth");
// Click sign in
await page.click('button:has-text("Sign in with Google")');
// Verify redirect to Google
await expect(page).toHaveURL(/accounts\.google\.com/);
// (In real tests, you'd mock OAuth or use test credentials)
});
test("pending user sees waiting screen", async ({ page, context }) => {
// Sign in as pending user
// (Auth setup here)
await page.goto("/dashboard");
// Should see pending approval screen
await expect(page.locator('text=Pending Approval')).toBeVisible();
await expect(page.locator('text=waiting for admin approval')).toBeVisible();
});
});
Task Creation Flow
Copy
// e2e/tasks.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Task Management", () => {
test.beforeEach(async ({ page }) => {
// Sign in as CSM (can create tasks)
await signIn(page, "[email protected]");
});
test("creates a new task", async ({ page }) => {
await page.goto("/tasks");
// Click create button
await page.click('button:has-text("Create Task")');
// Fill form
await page.fill('input[name="title"]', "E2E Test Task");
await page.selectOption('select[name="clientId"]', { label: "Test Client" });
await page.selectOption('select[name="pmId"]', { label: "John PM" });
await page.fill('textarea[name="description"]', "This is a test task");
await page.selectOption('select[name="priority"]', "3");
// Submit
await page.click('button[type="submit"]');
// Verify success
await expect(page.locator('text=Task created')).toBeVisible();
await expect(page).toHaveURL(/\/tasks\/task-/);
// Verify task appears in list
await page.goto("/tasks");
await expect(page.locator('text=E2E Test Task')).toBeVisible();
});
test("cannot create task without required fields", async ({ page }) => {
await page.goto("/tasks");
await page.click('button:has-text("Create Task")');
// Submit without filling form
await page.click('button[type="submit"]');
// Should show validation errors
await expect(page.locator('text=Title is required')).toBeVisible();
await expect(page.locator('text=Client is required')).toBeVisible();
});
});
Quote Approval Flow
Copy
// e2e/quotes.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Quote Workflow", () => {
test("full quote lifecycle", async ({ page, context }) => {
// 1. CSM creates quote
await signIn(page, "[email protected]");
await page.goto("/quotes");
await page.click('button:has-text("Create Quote")');
await page.fill('input[name="title"]', "New Feature");
await page.selectOption('select[name="clientId"]', { label: "Test Client" });
await page.click('button[type="submit"]');
const quoteUrl = page.url();
const quoteId = quoteUrl.match(/quote-(\w+)/)?.[1];
// 2. Developer adds scope
await signIn(page, "[email protected]");
await page.goto(quoteUrl);
await page.click('button:has-text("Add Development")');
await page.fill('textarea[name="scope"]', "Implement new feature");
await page.selectOption('select[name="tshirtSize"]', "m");
await page.click('button:has-text("Save")');
// 3. CSM reviews and sends to client
await signIn(page, "[email protected]");
await page.goto(quoteUrl);
await page.click('button:has-text("Send to Client")');
// Verify status changed
await expect(page.locator('text=Awaiting Client Approval')).toBeVisible();
// 4. Client approves
// (Would require client portal sign-in)
});
});
Client Portal Flow
Copy
// e2e/client-portal.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Client Portal", () => {
test("client can view their tasks", async ({ page }) => {
await page.goto("/client-portal/sign-in");
// Sign in
await page.fill('input[name="email"]', "[email protected]");
await page.fill('input[name="password"]', "password");
await page.click('button[type="submit"]');
// Navigate to tasks
await page.click('a:has-text("Tasks")');
// Verify tasks are visible
await expect(page.locator('[data-testid="task-card"]')).toHaveCount(3);
await expect(page.locator('text=Website Redesign')).toBeVisible();
});
test("client can submit help desk ticket", async ({ page }) => {
await signInAsClient(page, "[email protected]");
await page.goto("/client-portal/help-desk");
// Create ticket
await page.click('button:has-text("New Ticket")');
await page.fill('input[name="title"]', "Bug Report");
await page.fill('textarea[name="description"]', "Something is broken");
await page.selectOption('select[name="type"]', "bug");
await page.selectOption('select[name="priority"]', "high");
await page.click('button[type="submit"]');
// Verify success
await expect(page.locator('text=Ticket created')).toBeVisible();
await expect(page.locator('text=Bug Report')).toBeVisible();
});
});
Permission Tests
Copy
// e2e/permissions.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Access Control", () => {
test("IC cannot access admin panel", async ({ page }) => {
await signIn(page, "[email protected]");
// Try to access admin panel
await page.goto("/admin");
// Should redirect to dashboard
await expect(page).toHaveURL("/dashboard");
});
test("PM can view schedule", async ({ page }) => {
await signIn(page, "[email protected]");
await page.goto("/schedule");
// Should see schedule page
await expect(page.locator('h1:has-text("Schedule")')).toBeVisible();
});
test("CSM can create quotes", async ({ page }) => {
await signIn(page, "[email protected]");
await page.goto("/quotes");
// Should see create button
await expect(page.locator('button:has-text("Create Quote")')).toBeVisible();
});
});
Test Setup
Global Setup
Copy
// e2e/global-setup.ts
import { chromium, FullConfig } from "@playwright/test";
import { seedE2EData } from "./seed-e2e-data";
async function globalSetup(config: FullConfig) {
// Seed test data
await seedE2EData();
// Optionally: Create authenticated state files
const browser = await chromium.launch();
const page = await browser.newPage();
// Sign in and save state
await page.goto("http://localhost:3000/auth");
// (Sign in logic)
await page.context().storageState({ path: "auth-state.json" });
await browser.close();
}
export default globalSetup;
Seed Data
Copy
// e2e/seed-e2e-data.ts
import { db } from "@/lib/db";
import { user, client, task } from "@/lib/db/schema";
export async function seedE2EData() {
// Create test users
await db.insert(user).values([
{ email: "[email protected]", name: "Test Admin", accessLevel: "admin" },
{ email: "[email protected]", name: "Test Developer", workType: "development" },
{ email: "[email protected]", name: "Test PM", workType: "pm" },
]);
// Create test client
const [testClient] = await db.insert(client).values({
name: "Test Client",
slug: "test-client",
}).returning();
// Create test tasks
await db.insert(task).values([
{ displayId: 1, title: "Test Task 1", clientId: testClient.id },
{ displayId: 2, title: "Test Task 2", clientId: testClient.id },
]);
}
Running E2E Tests
Basic Commands
Copy
# Run all E2E tests (headless)
npm run test:e2e
# Run with UI (interactive)
npm run test:e2e:ui
# Run specific file
npx playwright test e2e/auth.spec.ts
# Run specific test
npx playwright test -g "creates a new task"
# Run with debugging
npx playwright test --debug
# Run in headed mode
npx playwright test --headed
Watch Mode
Copy
# Run in UI mode (auto-reruns on changes)
npx playwright test --ui
Debugging
Trace Viewer
Playwright records traces on failures:Copy
# View trace after test failure
npx playwright show-trace trace.zip
- Screenshots at each step
- Network requests
- Console logs
- DOM snapshots
Code Generator
Generate test code by recording actions:Copy
npx playwright codegen http://localhost:3000
Debug Mode
Copy
# Run with debugger
npx playwright test --debug
# Run specific test with debugger
npx playwright test -g "creates task" --debug
Best Practices
Test Real User Flows
Test Real User Flows
Focus on what users actually do:
Copy
// ✅ Good: Complete user flow
test("user creates and completes task", async ({ page }) => {
await signIn(page);
await createTask(page, "New Task");
await navigateToTask(page, "New Task");
await completeTask(page);
});
// ❌ Bad: Testing implementation details
test("API returns 200", async ({ page }) => {
// Too low-level
});
Use Reliable Locators
Use Reliable Locators
Prefer semantic locators over CSS selectors:
Copy
// ✅ Good: Semantic, resilient
page.getByRole("button", { name: "Create Task" });
page.getByLabel("Task title");
// ❌ Bad: Fragile, breaks on styling changes
page.locator('.btn-primary.create-btn');
Clean Up After Tests
Clean Up After Tests
Delete test data to avoid pollution:
Copy
test.afterEach(async () => {
// Delete created tasks
await db.delete(task).where(like(task.title, "E2E Test%"));
});
Use Page Object Pattern
Use Page Object Pattern
Extract common interactions:
Copy
// e2e/pages/tasks.ts
export class TasksPage {
constructor(private page: Page) {}
async createTask(title: string, client: string) {
await this.page.click('button:has-text("Create Task")');
await this.page.fill('input[name="title"]', title);
await this.page.selectOption('select[name="clientId"]', client);
await this.page.click('button[type="submit"]');
}
}
// Usage
const tasksPage = new TasksPage(page);
await tasksPage.createTask("New Task", "Test Client");
CI Integration
E2E tests run in GitHub Actions:Copy
# .github/workflows/e2e.yml
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/