Skip to main content
Sabo includes a complete Playwright E2E testing suite covering authentication, dashboard, marketing pages, blog, changelog, and legal pages. Tests run across multiple browsers and viewports to ensure cross-platform compatibility.

Overview

Playwright is a powerful end-to-end testing framework that allows you to test your application in real browsers. Sabo’s testing setup provides:
  • Cross-browser testing (Chromium, Firefox, WebKit)
  • Mobile viewport testing (Pixel 5, iPhone 12)
  • Comprehensive coverage across 16 spec files spanning authentication, dashboard, marketing, blog, changelog, and legal flows
  • Authentication helpers for testing protected routes
  • Parallel test execution for fast feedback
  • Visual debugging with Playwright UI mode
  • HTML test reports with screenshots and traces
Playwright tests run against a production build (pnpm build && pnpm start) to ensure tests match real-world behavior.
Need deeper coverage (API testing, fixtures, component testing)? Keep the official Playwright documentation open while following this guide.

Quick Start

1

Install Playwright

Playwright is already included in package.json. If you need to reinstall or update:
# Install dependencies (includes Playwright)
pnpm install

# Install Playwright browsers (first time only)
pnpm exec playwright install
Playwright will download Chromium, Firefox, and WebKit browsers (~300MB total).
2

Run your first test

Start the test suite with a single command:
# Run all tests
pnpm test:e2e
This command will:
  1. Build your application (pnpm build)
  2. Start a production server (pnpm start on port 3000)
  3. Run all tests across all configured browsers
  4. Generate an HTML report
Tests run in parallel by default for speed. On CI, tests run sequentially to avoid flakiness.
3

View test results

After tests complete, view the detailed HTML report:
# Open test report in browser
pnpm test:e2e:report
The report includes:
  • Pass/fail status for each test
  • Screenshots of failures
  • Execution traces for debugging
  • Performance metrics
Test reports are automatically generated in playwright-report/ directory.

Test Commands

Sabo provides several npm scripts for different testing workflows:
package.json
{
  "scripts": {
    "test:e2e": "playwright test",                 // Run all tests
    "test:e2e:ui": "playwright test --ui",         // Interactive UI mode
    "test:e2e:headed": "playwright test --headed", // See browser while testing
    "test:e2e:debug": "playwright test --debug",   // Step-by-step debugging
    "test:e2e:report": "playwright show-report"    // View HTML report
  }
}

Command Breakdown

When to use: CI/CD pipelines, pre-commit checks, final verification before deployment.
pnpm test:e2e
Behavior:
  • Runs all tests in headless mode (no browser UI)
  • Tests run in parallel for speed
  • Automatically builds and starts your app
  • Generates HTML report at the end
  • Exit code 0 (success) or 1 (failure) for CI integration
Output:
Running 85 tests using 5 workers
  ✓ homepage.spec.ts:8:5 › Homepage › should load homepage successfully (1.2s)
  ✓ sign-in.spec.ts:8:5 › Sign In Page › should load sign in page successfully (890ms)
  ...
  85 passed (1.5m)
When to use: Developing new tests, debugging flaky tests, exploring test coverage.
pnpm test:e2e:ui
Behavior:
  • Opens Playwright’s interactive test runner UI
  • Watch mode: tests re-run when files change
  • Time-travel debugging with DOM snapshots
  • Click through test steps one at a time
  • Filter tests by name, file, or status
Features:
  • See test execution in real-time
  • Inspect DOM at each step
  • View network requests and console logs
  • Record new tests by clicking in your app
  • Compare screenshots side-by-side
UI mode is the fastest way to write new tests. Use the “Record” feature to generate test code automatically.
When to use: Debugging visual issues, understanding test flow, demonstrating tests to team.
pnpm test:e2e:headed
Behavior:
  • Tests run with visible browser windows
  • See exactly what Playwright sees
  • Slower than headless mode
  • Useful for debugging timing issues
Example workflow:
  1. Run pnpm test:e2e:headed
  2. Watch browser open and navigate your app
  3. Identify visual bugs or timing issues
  4. Fix and re-run specific tests
When to use: Investigating test failures, understanding complex interactions, learning Playwright.
pnpm test:e2e:debug
Behavior:
  • Opens Playwright Inspector
  • Pauses at each test step
  • Step forward/backward through test
  • Explore locators and selectors
  • Modify test code on the fly
Debugging features:
  • Pick locator: Click any element to generate a selector
  • Step over/into: Control test execution
  • Console: Run Playwright commands interactively
  • Source: View test code with current line highlighted
# Debug a specific test file
pnpm exec playwright test tests/e2e/auth/sign-in.spec.ts --debug

# Debug a specific test by name
pnpm exec playwright test --grep "should load homepage" --debug
When to use: After test run completes, reviewing failures, sharing results with team.
pnpm test:e2e:report
Behavior:
  • Opens HTML report in your default browser
  • Shows pass/fail status for all tests
  • Includes screenshots of failures
  • Execution traces for debugging
  • Test duration and retry information
Report features:
  • Filter by status (passed/failed/flaky/skipped)
  • Search tests by name
  • View screenshots and videos
  • Download trace files for offline debugging
  • Compare test results across runs

Test Configuration

Playwright configuration is defined in playwright.config.ts at the project root:
playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",                       // Test files location
  fullyParallel: true,                      // Run tests in parallel
  forbidOnly: !!process.env.CI,            // Fail CI if test.only is left
  retries: process.env.CI ? 2 : 0,         // Retry flaky tests on CI
  workers: process.env.CI ? 1 : undefined, // Sequential on CI, parallel locally
  reporter: "html",                         // HTML report generator
  
  use: {
    baseURL: "http://localhost:3000",      // Base URL for page.goto('/')
    trace: "on-first-retry",               // Capture traces on retry
  },

  // Test across multiple browsers
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "Mobile Chrome", use: { ...devices["Pixel 5"] } },
    { name: "Mobile Safari", use: { ...devices["iPhone 12"] } },
  ],

  // Auto-start dev server before tests
  webServer: {
    command: "pnpm build && pnpm start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000, // 2 minutes for build + start
  },
});

Configuration Options Explained

testDir: "./tests"
All test files must be inside the tests/ directory. Playwright looks for files ending in .spec.ts or .test.ts.Sabo’s test structure:
tests/
└── e2e/
    ├── auth/           # Authentication tests
    ├── dashboard/      # Dashboard tests
    ├── marketing/      # Marketing page tests
    ├── blog/           # Blog tests
    ├── changelog/      # Changelog tests
    ├── legal/          # Legal page tests
    └── helpers/        # Test utilities
fullyParallel: true
Enables parallel test execution across multiple worker processes. Dramatically speeds up test runs.Impact:
  • Without parallel: 85 tests in ~8 minutes
  • With parallel (5 workers): 85 tests in ~1.5 minutes
Tests must be independent (no shared state) for parallel execution to work correctly.
retries: process.env.CI ? 2 : 0
Automatically retry failed tests on CI environments. Local failures don’t retry (faster feedback).Why retry on CI:
  • Network latency
  • Resource constraints
  • Timing issues in CI environments
Best practice: Fix flaky tests instead of relying on retries. Use retries as a temporary safety net.
workers: process.env.CI ? 1 : undefined
  • Local: Uses all CPU cores (undefined = auto)
  • CI: Sequential execution (1 worker) for stability
Override workers:
# Run with specific number of workers
pnpm exec playwright test --workers=3

# Run tests sequentially (helpful for debugging)
pnpm exec playwright test --workers=1
use: {
  baseURL: "http://localhost:3000"
}
Allows relative URLs in tests:
// With baseURL
await page.goto("/");           // Goes to http://localhost:3000/
await page.goto("/dashboard");  // Goes to http://localhost:3000/dashboard

// Without baseURL (not recommended)
await page.goto("http://localhost:3000/dashboard");
webServer: {
  command: "pnpm build && pnpm start",
  url: "http://localhost:3000",
  reuseExistingServer: !process.env.CI,
  timeout: 120 * 1000,
}
Playwright automatically:
  1. Builds your app (pnpm build)
  2. Starts production server (pnpm start)
  3. Waits for server to be ready (checks url)
  4. Runs tests
  5. Shuts down server after tests complete
reuseExistingServer:
  • Local: Reuses running server (faster during development)
  • CI: Always starts fresh server (ensures clean state)
Keep your dev server running (pnpm dev) during test development. Set reuseExistingServer: true to avoid rebuilding.

Test Structure

Sabo’s tests are organized by feature area in tests/e2e/:
tests/e2e/
├── auth/                           # Authentication flows
│   ├── sign-in.spec.ts            # Sign in page tests
│   ├── sign-up.spec.ts            # Registration tests
│   └── password-reset.spec.ts     # Password reset flow

├── dashboard/                      # Protected dashboard area
│   ├── dashboard.spec.ts          # Main dashboard page
│   ├── settings-general.spec.ts   # General settings
│   ├── settings-account.spec.ts   # Account settings
│   ├── settings-billing.spec.ts   # Billing settings
│   └── settings-notifications.spec.ts

├── marketing/                      # Public marketing pages
│   ├── homepage.spec.ts           # Homepage tests
│   ├── pricing.spec.ts            # Pricing page tests
│   └── contact.spec.ts            # Contact form tests

├── blog/                           # Blog system
│   ├── blog-list.spec.ts          # Blog post listing
│   └── blog-post.spec.ts          # Individual post page

├── changelog/                      # Changelog system
│   ├── changelog-list.spec.ts     # Changelog listing
│   └── changelog-detail.spec.ts   # Changelog entry detail

├── legal/                          # Legal pages
│   └── legal-pages.spec.ts        # Privacy, ToS, Cookie Policy

└── helpers/                        # Test utilities
    └── auth.ts                     # Authentication helpers

Test File Anatomy

Here’s a typical test file structure:
tests/e2e/marketing/contact.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Contact Page", () => {
  // Runs before each test in this describe block
  test.beforeEach(async ({ page }) => {
    await page.goto("/contact");
  });

  test("should load contact page successfully", async ({ page }) => {
    await expect(page).toHaveTitle(/Contact/);
  });

  test("should display contact form", async ({ page }) => {
    const form = page.locator("form");
    await expect(form).toBeVisible();
  });

  test("should validate email format", async ({ page }) => {
    const emailInput = page.locator('input[type="email"]');
    await emailInput.fill("invalid-email");
    
    const submitButton = page.locator('button[type="submit"]');
    await submitButton.click();
    
    const emailError = page.locator("text=/valid email/i");
    await expect(emailError).toBeVisible();
  });

  test("should accept valid form submission", async ({ page }) => {
    await page.locator('input[name="name"]').fill("Test User");
    await page.locator('input[type="email"]').fill("[email protected]");
    await page.locator('textarea').fill("This is a test message.");
    await page.locator('button[type="submit"]').click();

    const successMessage = page.locator("text=/success|thank you/i");
    await expect(successMessage).toBeVisible();
  });
});

Test Patterns

test.beforeEach(async ({ page }) => {
  // Navigate to page
  await page.goto("/dashboard");
  
  // Set up authentication (if needed)
  await setupAuthenticatedUser(page);
  
  // Reset state
  await page.evaluate(() => localStorage.clear());
});
Use cases:
  • Navigate to a common page
  • Set up authentication
  • Clear browser state
  • Inject test data
Playwright provides multiple ways to find elements:
// By role (most recommended - accessible)
page.getByRole("button", { name: "Submit" })
page.getByRole("link", { name: "Sign In" })

// By text content
page.locator("text=Hello World")
page.locator("text=/success|thank you/i") // Regex, case-insensitive

// By CSS selector
page.locator("form")
page.locator('input[type="email"]')
page.locator(".btn-primary")

// By test ID (best for dynamic content)
page.locator('[data-testid="user-menu"]')

// By placeholder
page.getByPlaceholder("Enter your email")

// By label text (for form inputs)
page.getByLabel("Email address")
Best practices:
  1. Prefer getByRole for accessibility
  2. Use data-testid for dynamic content
  3. Avoid brittle selectors (CSS classes that may change)
  4. Use regex for flexible text matching
// Page assertions
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveTitle(/Dashboard/);

// Element visibility
await expect(page.locator("form")).toBeVisible();
await expect(page.locator(".loading")).toBeHidden();

// Element state
await expect(page.locator("button")).toBeEnabled();
await expect(page.locator("input")).toBeDisabled();
await expect(page.locator("checkbox")).toBeChecked();

// Text content
await expect(page.locator("h1")).toHaveText("Welcome");
await expect(page.locator("p")).toContainText("Hello");

// Count
await expect(page.locator("li")).toHaveCount(5);

// Attribute
await expect(page.locator("a")).toHaveAttribute("href", "/about");
Playwright’s assertions are auto-waiting: they retry until the condition is met or timeout occurs (default 30s).

Writing Your First Test

Let’s write a test for a new feature page step by step.
1

Create test file

Create a new file in the appropriate directory:
tests/e2e/features/new-feature.spec.ts
Name your test file after the feature or page you’re testing, ending with .spec.ts.
2

Import Playwright

tests/e2e/features/new-feature.spec.ts
import { test, expect } from "@playwright/test";
3

Write your first test

tests/e2e/features/new-feature.spec.ts
import { test, expect } from "@playwright/test";

test.describe("New Feature Page", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/new-feature");
  });

  test("should load the page successfully", async ({ page }) => {
    // Check page loaded
    await expect(page).toHaveURL("/new-feature");
    
    // Check title is correct
    await expect(page).toHaveTitle(/New Feature/);
    
    // Check main heading is visible
    const heading = page.locator("h1");
    await expect(heading).toBeVisible();
    await expect(heading).toHaveText("New Feature");
  });
});
4

Run your test

# Run only your new test file
pnpm exec playwright test new-feature.spec.ts

# Run with UI for easier debugging
pnpm exec playwright test new-feature.spec.ts --ui
If your test passes, you’ll see a green checkmark. If it fails, Playwright will show exactly which assertion failed and why.
5

Add more test cases

test("should display feature description", async ({ page }) => {
  const description = page.locator('p[class*="description"]');
  await expect(description).toBeVisible();
  await expect(description).toContainText("This feature allows you to");
});

test("should have a call-to-action button", async ({ page }) => {
  const ctaButton = page.getByRole("button", { name: /get started/i });
  await expect(ctaButton).toBeVisible();
  await expect(ctaButton).toBeEnabled();
});

test("should navigate when CTA is clicked", async ({ page }) => {
  const ctaButton = page.getByRole("button", { name: /get started/i });
  await ctaButton.click();
  
  // Should redirect to sign-up
  await expect(page).toHaveURL("/sign-up");
});

Testing Protected Routes

Many pages in your app require authentication. Sabo provides an auth helper to set up authenticated sessions in tests.

Authentication Helper

The auth helper is located at tests/e2e/helpers/auth.ts:
tests/e2e/helpers/auth.ts
import { Page } from "@playwright/test";

export async function setupAuthenticatedUser(page: Page): Promise<void> {
  const { createClient } = await import("@supabase/supabase-js");
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

  const { data, error } = await supabase.auth.signInWithPassword({
    email: process.env.TEST_USER_EMAIL!,
    password: process.env.TEST_USER_PASSWORD!,
  });

  if (error) {
    throw new Error(`Failed to authenticate test user: ${error.message}`);
  }

  await page.context().addCookies([
    {
      name: "sb-access-token",
      value: data.session.access_token,
      domain: "localhost",
      path: "/",
      httpOnly: true,
      secure: false,
      sameSite: "Lax",
    },
    {
      name: "sb-refresh-token",
      value: data.session.refresh_token,
      domain: "localhost",
      path: "/",
      httpOnly: true,
      secure: false,
      sameSite: "Lax",
    },
  ]);

  console.warn(
    "setupAuthenticatedUser is not implemented yet. Add test credentials to .env.test and uncomment the implementation."
  );
}
The helper logs a warning by default as a reminder to configure test credentials. Once .env.test is set up and you have confirmed the helper works, delete or comment out the console.warn line to keep test output clean.

Using the Auth Helper

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

test.describe("Dashboard", () => {
  test.beforeEach(async ({ page }) => {
    await setupAuthenticatedUser(page);
    await page.goto("/dashboard");
  });

  test("should show user menu", async ({ page }) => {
    const userMenu = page
      .locator("button")
      .filter({ has: page.locator('img[alt*="avatar" i], svg') })
      .first();

    await userMenu.click();
    await expect(
      page.locator("text=/account|billing|log out/i").first()
    ).toBeVisible();
  });
});
In the repository, tests that require authentication are marked with test.skip until setupAuthenticatedUser() is configured. Once your test credentials are in place, remove the skips to exercise the protected flows.
Want to reuse Playwright’s built-in authentication storage? See the official Playwright authentication guide. If you need to understand how Supabase sessions are issued in Sabo before writing tests, review Auth with Supabase.

Setting Up Test Credentials

Create a test user in your Supabase dashboard, then add credentials to .env.test:
.env.test
# Test user credentials
TEST_USER_EMAIL=[email protected]
TEST_USER_PASSWORD=your_secure_test_password

# Supabase credentials (same as .env.local)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Never commit .env.test to version control. Add it to .gitignore. Use a dedicated test user, not a real user account.
Playwright doesn’t automatically load .env.test. Either export these variables in your shell before running tests or run commands through the dotenv CLI: pnpm exec dotenv -e .env.test -- pnpm test:e2e. This ensures helpers like setupAuthenticatedUser() can read the credentials.

Common Test Patterns

Testing Forms

test("should submit contact form successfully", async ({ page }) => {
  // Fill form fields
  await page.locator('input[name="firstName"]').fill("John");
  await page.locator('input[name="lastName"]').fill("Doe");
  await page.locator('input[type="email"]').fill("[email protected]");
  await page.locator('textarea[name="message"]').fill("Hello, this is a test message.");
  
  // Submit form
  await page.locator('button[type="submit"]').click();
  
  // Verify success message
  await expect(page.locator("text=/success|thank you/i")).toBeVisible();
  
  // Verify form is cleared
  await expect(page.locator('input[name="firstName"]')).toHaveValue("");
});

Testing Navigation

test("should navigate to pricing page", async ({ page }) => {
  await page.goto("/");
  
  // Click navigation link
  const pricingLink = page.getByRole("link", { name: /pricing/i });
  await pricingLink.click();
  
  // Verify navigation
  await expect(page).toHaveURL("/pricing");
  await expect(page.locator("h1")).toHaveText("Pricing");
});

Testing Responsive Design

test("should be responsive on mobile", async ({ page }) => {
  // Set mobile viewport
  await page.setViewportSize({ width: 375, height: 667 });
  
  await page.goto("/");
  
  // Mobile menu should be visible
  const mobileMenuButton = page.locator('button[aria-label="Menu"]');
  await expect(mobileMenuButton).toBeVisible();
  
  // Open mobile menu
  await mobileMenuButton.click();
  
  // Mobile nav should appear
  const mobileNav = page.locator('nav[data-mobile]');
  await expect(mobileNav).toBeVisible();
});

Testing Conditional Rendering

test("should show billing section for paid users", async ({ page }) => {
  await setupAuthenticatedUser(page);
  await page.goto("/dashboard/settings/billing");
  
  // Check if user has paid plan
  const planBadge = page.locator('[data-plan]');
  const planType = await planBadge.getAttribute("data-plan");
  
  if (planType === "pro" || planType === "enterprise") {
    // Paid users should see billing history
    await expect(page.locator("text=/billing history/i")).toBeVisible();
  } else {
    // Free users should see upgrade prompt
    await expect(page.locator("text=/upgrade/i")).toBeVisible();
  }
});

Testing Async Operations

test("should load and display blog posts", async ({ page }) => {
  await page.goto("/blog");
  
  // Wait for posts to load
  await page.waitForSelector('[data-testid="blog-post"]', { timeout: 5000 });
  
  // Verify posts are displayed
  const posts = page.locator('[data-testid="blog-post"]');
  const postCount = await posts.count();
  
  expect(postCount).toBeGreaterThan(0);
  
  // Check first post has required elements
  const firstPost = posts.first();
  await expect(firstPost.locator("h2")).toBeVisible();
  await expect(firstPost.locator("img")).toBeVisible();
  await expect(firstPost.locator("time")).toBeVisible();
});

Debugging Tests

The Playwright Inspector provides step-by-step debugging:
# Debug specific test
pnpm exec playwright test auth/sign-in.spec.ts --debug
Features:
  • Step through: Execute one action at a time
  • Pick locator: Click elements to generate selectors
  • Console: Run Playwright commands interactively
  • Screenshots: Capture state at each step
Common debugging commands:
// In Playwright Inspector console
page.locator('button') // Test selector
page.screenshot() // Capture current state
page.pause() // Add breakpoint in test code
Add console output to understand test flow:
test("debugging test", async ({ page }) => {
  await page.goto("/dashboard");
  
  console.log("Current URL:", page.url());
  
  const button = page.locator("button");
  const isVisible = await button.isVisible();
  console.log("Button visible:", isVisible);
  
  const count = await page.locator("li").count();
  console.log("List item count:", count);
});
View output:
pnpm test:e2e -- --headed
Console logs appear in terminal output.
Capture screenshots at specific points:
test("screenshot debugging", async ({ page }) => {
  await page.goto("/dashboard");
  
  // Take screenshot
  await page.screenshot({ path: "screenshots/dashboard.png" });
  
  // Screenshot specific element
  const sidebar = page.locator("aside");
  await sidebar.screenshot({ path: "screenshots/sidebar.png" });
});
Screenshots are automatically captured on test failures. Find them in test-results/ directory.
Control which tests run during debugging:
// Run only this test (ignore all others)
test.only("focused test", async ({ page }) => {
  // ...
});

// Skip this test temporarily
test.skip("broken test to fix later", async ({ page }) => {
  // ...
});

// Conditional skip
test("auth test", async ({ page }) => {
  test.skip(!process.env.TEST_USER_EMAIL, "Test credentials not configured");
  // ...
});
Remove test.only() before committing! CI will fail if test.only() is detected (forbidOnly: true in config).
Trace files provide complete test execution history:
# Run test with trace (automatically enabled on failures)
pnpm test:e2e

# View trace from failed test
pnpm exec playwright show-trace test-results/path/to/trace.zip
Trace viewer features:
  • Timeline of all actions
  • Screenshots at each step
  • Network requests and responses
  • Console logs and errors
  • DOM snapshots (time-travel debugging)
Manual trace capture:
test("with trace", async ({ page }) => {
  await page.context().tracing.start({ screenshots: true, snapshots: true });
  
  // Your test actions
  await page.goto("/dashboard");
  
  await page.context().tracing.stop({ path: "trace.zip" });
});

Best Practices

For more examples straight from the Playwright team, refer to the official Best Practices guide. It complements the patterns outlined below.
Each test should be completely isolated and not depend on other tests.Bad:
let userId: string;

test("create user", async ({ page }) => {
  userId = await createUser(); // Sets global state
});

test("update user", async ({ page }) => {
  await updateUser(userId); // Depends on previous test
});
Good:
test("create user", async ({ page }) => {
  const userId = await createUser();
  // Test only user creation
});

test("update user", async ({ page }) => {
  const userId = await createUser(); // Create fresh user
  await updateUser(userId);
  // Test only user update
});
Test names should clearly describe what is being tested.Bad:
test("test 1", async ({ page }) => { });
test("it works", async ({ page }) => { });
Good:
test("should display validation error for empty email field", async ({ page }) => { });
test("should redirect to dashboard after successful sign in", async ({ page }) => { });
Pattern: “should [expected behavior] when [condition]”
Use selectors that reflect how users interact with your app.Priority order:
  1. Role-based (best for accessibility)
    page.getByRole("button", { name: "Submit" })
    
  2. Label-based (for form inputs)
    page.getByLabel("Email address")
    
  3. Test IDs (for dynamic content)
    page.locator('[data-testid="user-menu"]')
    
  4. Text content (for static text)
    page.locator("text=Welcome")
    
Avoid:
  • CSS classes that may change: .btn-primary-v2-new
  • Complex CSS selectors: div > ul > li:nth-child(3)
  • XPath selectors (hard to maintain)
Fast tests provide quick feedback and encourage frequent testing.Tips:
  • Use baseURL to avoid full URLs
  • Reuse authenticated sessions when possible
  • Mock external API calls (if needed)
  • Run tests in parallel (fullyParallel: true)
  • Use page.waitForLoadState("domcontentloaded") instead of arbitrary timeouts
Bad:
await page.waitForTimeout(3000); // Arbitrary delay
Good:
await page.waitForSelector('[data-testid="loaded"]'); // Wait for specific element
Remove test data after tests to ensure isolation.
test.afterEach(async ({ page }) => {
  // Clean up: delete test user, clear database, etc.
  await cleanupTestUser();
});

test.afterAll(async () => {
  // Clean up: close connections, delete temp files, etc.
});
Playwright automatically clears browser cookies, local storage, and session storage between tests. You only need to clean up server-side data.

CI/CD Integration

Playwright tests can run automatically in your CI/CD pipeline.
For provider-specific examples (GitHub Actions, GitLab, Jenkins, Azure), refer to Playwright’s CI guide; the workflow below shows how we configure GitHub Actions for Sabo.

GitHub Actions Example

.github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          
      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9
          
      - name: Install dependencies
        run: pnpm install
        
      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps
        
      - name: Run Playwright tests
        run: pnpm test:e2e
        env:
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
          
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Vercel Integration

Run tests before deployment:
vercel.json
{
  "buildCommand": "pnpm build && pnpm test:e2e",
  "installCommand": "pnpm install && pnpm exec playwright install"
}
Running E2E tests on every deployment can slow down your pipeline. Consider running them only on main branch or scheduled runs.

Troubleshooting

Cause: Browser crashed or was closed unexpectedly.Fix:
  1. Check for memory issues (tests using too much RAM)
  2. Reduce parallel workers: pnpm exec playwright test --workers=1
  3. Update Playwright: pnpm update @playwright/test
  4. Check for infinite loops or long-running operations
Cause: Race conditions, timing issues, or network dependencies.Fix:
  1. Avoid page.waitForTimeout() - use specific waits instead
  2. Use auto-waiting assertions (await expect(...))
  3. Wait for network to be idle: await page.waitForLoadState("networkidle")
  4. Increase timeout for slow operations: { timeout: 60000 }
  5. Mock external API calls to remove network dependency
Cause: Element is hidden, covered, or not yet rendered.Fix:
  1. Wait for element to be visible first:
    await page.locator("button").waitFor({ state: "visible" });
    await page.locator("button").click();
    
  2. Scroll element into view:
    await page.locator("button").scrollIntoViewIfNeeded();
    await page.locator("button").click();
    
  3. Check for overlays or modals covering the element
  4. Use force: true (last resort):
    await page.locator("button").click({ force: true });
    
Cause: Missing test credentials or incorrect Supabase configuration.Fix:
  1. Verify .env.test exists with correct credentials
  2. Check test user exists in Supabase dashboard
  3. Ensure NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set
  4. Verify cookie domain matches (localhost for local tests)
  5. Check Supabase session is not expired
Cause: Different environment, timing, or dependencies.Fix:
  1. Run tests sequentially on CI: workers: 1 in config
  2. Enable retries on CI: retries: 2 in config
  3. Check environment variables are set in CI
  4. Increase timeouts for slower CI environments
  5. Use webServer.reuseExistingServer: !process.env.CI to ensure fresh server
Cause: Network issues, permissions, or disk space.Fix:
# Install with dependencies
pnpm exec playwright install --with-deps

# Install specific browser
pnpm exec playwright install chromium

# Clear cache and reinstall
rm -rf ~/.cache/ms-playwright
pnpm exec playwright install
Check disk space:
df -h
Playwright browsers require ~1GB of disk space.