Testing
E2E Testing
E2E Testing with Playwright
Global Watch uses Playwright for end-to-end testing. E2E tests verify complete user flows work correctly across the entire application stack, including the browser, server, and database.
Configuration
Playwright Setup
The Playwright configuration is located at apps/e2e/playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
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'] },
},
],
webServer: {
command: 'pnpm --filter web dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Project Structure
E2E tests are organized by feature:
apps/e2e/
├── tests/
│ ├── authentication/
│ │ ├── auth.po.ts # Page Object
│ │ ├── sign-in.spec.ts
│ │ └── sign-up.spec.ts
│ ├── projects/
│ │ ├── projects.po.ts
│ │ ├── create-project.spec.ts
│ │ └── project-members.spec.ts
│ ├── team-accounts/
│ │ ├── team-accounts.po.ts
│ │ └── team-management.spec.ts
│ └── fixtures/
│ ├── test-data.ts
│ └── auth.fixture.ts
├── playwright.config.ts
└── package.jsonPage Object Pattern
Use Page Objects to encapsulate page interactions:
// tests/authentication/auth.po.ts
import { Page, Locator } from '@playwright/test';
export class AuthPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-test="email-input"]');
this.passwordInput = page.locator('[data-test="password-input"]');
this.signInButton = page.locator('[data-test="sign-in-button"]');
this.errorMessage = page.locator('[data-test="error-message"]');
}
async goto() {
await this.page.goto('/auth/sign-in');
}
async signIn(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async waitForRedirect() {
// Use regex for flexible URL matching
await this.page.waitForURL(/\/(home|workspace)/);
}
async getErrorMessage() {
return this.errorMessage.textContent();
}
}Using Page Objects in Tests
// tests/authentication/sign-in.spec.ts
import { test, expect } from '@playwright/test';
import { AuthPage } from './auth.po';
test.describe('Sign In', () => {
let authPage: AuthPage;
test.beforeEach(async ({ page }) => {
authPage = new AuthPage(page);
await authPage.goto();
});
test('should sign in with valid credentials', async () => {
await authPage.signIn('user@example.com', 'password123');
await authPage.waitForRedirect();
expect(authPage.page.url()).toContain('/home');
});
test('should show error for invalid credentials', async () => {
await authPage.signIn('user@example.com', 'wrong-password');
const error = await authPage.getErrorMessage();
expect(error).toContain('Invalid credentials');
});
});Writing E2E Tests
Basic Test Structure
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test.beforeEach(async ({ page }) => {
// Setup before each test
await page.goto('/');
});
test('should perform expected action', async ({ page }) => {
// Arrange
await page.click('[data-test="action-button"]');
// Act
await page.fill('[data-test="input-field"]', 'test value');
await page.click('[data-test="submit-button"]');
// Assert
await expect(page.locator('[data-test="success-message"]')).toBeVisible();
});
});Testing User Flows
// tests/projects/create-project.spec.ts
import { test, expect } from '@playwright/test';
import { AuthPage } from '../authentication/auth.po';
import { ProjectsPage } from './projects.po';
test.describe('Create Project Flow', () => {
test.beforeEach(async ({ page }) => {
// Authenticate before each test
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.signIn('owner@example.com', 'password123');
await authPage.waitForRedirect();
});
test('should create a new project', async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
// Click create button
await page.click('[data-test="create-project-button"]');
// Fill form
await page.fill('[data-test="project-name-input"]', 'New Project');
await page.fill('[data-test="project-description-input"]', 'Description');
// Submit
await page.click('[data-test="submit-project-button"]');
// Verify success
await expect(page.locator('[data-test="success-toast"]')).toBeVisible();
await expect(page.locator('text=New Project')).toBeVisible();
});
test('should validate required fields', async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
await page.click('[data-test="create-project-button"]');
await page.click('[data-test="submit-project-button"]');
await expect(page.locator('[data-test="name-error"]')).toBeVisible();
});
});Testing Multi-Tenant Flows
Global Watch uses subdomain-based multi-tenancy:
// tests/team-accounts/team-management.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Team Account Management', () => {
test('should access team workspace via subdomain', async ({ page }) => {
// Navigate to team subdomain
await page.goto('http://acme.localhost.direct:3000');
// Verify team context
await expect(page.locator('[data-test="team-name"]')).toContainText('Acme');
});
test('should switch between personal and team accounts', async ({ page }) => {
// Start at personal account
await page.goto('http://app.localhost.direct:3000/home');
// Open account switcher
await page.click('[data-test="account-switcher"]');
// Select team account
await page.click('[data-test="team-acme"]');
// Verify redirect to team subdomain
await expect(page).toHaveURL(/acme\.localhost\.direct/);
});
});Testing with Authentication
Use fixtures for authenticated tests:
// tests/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { AuthPage } from '../authentication/auth.po';
type AuthFixtures = {
authenticatedPage: Page;
ownerPage: Page;
memberPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.signIn('user@example.com', 'password123');
await authPage.waitForRedirect();
await use(page);
},
ownerPage: async ({ page }, use) => {
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.signIn('owner@example.com', 'password123');
await authPage.waitForRedirect();
await use(page);
},
memberPage: async ({ page }, use) => {
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.signIn('member@example.com', 'password123');
await authPage.waitForRedirect();
await use(page);
},
});
export { expect } from '@playwright/test';Using the fixture:
// tests/projects/project-permissions.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
test.describe('Project Permissions', () => {
test('owner should see all project settings', async ({ ownerPage }) => {
await ownerPage.goto('/projects/123/settings');
await expect(ownerPage.locator('[data-test="danger-zone"]')).toBeVisible();
await expect(ownerPage.locator('[data-test="delete-button"]')).toBeVisible();
});
test('member should not see danger zone', async ({ memberPage }) => {
await memberPage.goto('/projects/123/settings');
await expect(memberPage.locator('[data-test="danger-zone"]')).not.toBeVisible();
});
});Selectors Best Practices
Use Data-Test Attributes
// ✅ GOOD - Stable selectors
await page.click('[data-test="submit-button"]');
await page.fill('[data-test="email-input"]', 'test@example.com');
// ❌ AVOID - Fragile selectors
await page.click('button.primary-btn');
await page.click('text=Submit');
await page.click('.form-container > div:nth-child(2) > button');Add Data-Test Attributes to Components
// In your React components
<Button data-test="submit-button" type="submit">
Submit
</Button>
<Input
data-test="email-input"
type="email"
placeholder="Enter email"
/>
<div data-test="error-message" className="text-red-500">
{error}
</div>Selector Priority
- data-test attributes - Most stable
- ARIA roles - Accessible and semantic
- Text content - For user-visible text
- CSS selectors - Last resort
// Priority order
await page.locator('[data-test="login-button"]').click();
await page.getByRole('button', { name: 'Login' }).click();
await page.getByText('Login').click();
await page.locator('.login-btn').click();Handling Async Operations
Wait for Elements
// Wait for element to be visible
await expect(page.locator('[data-test="loading"]')).not.toBeVisible();
await expect(page.locator('[data-test="content"]')).toBeVisible();
// Wait for specific text
await expect(page.locator('[data-test="status"]')).toContainText('Success');
// Wait for URL change
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/projects\/\d+/);Wait for Network Requests
// Wait for API response
const responsePromise = page.waitForResponse('**/api/projects');
await page.click('[data-test="load-projects"]');
const response = await responsePromise;
expect(response.status()).toBe(200);
// Wait for navigation
await Promise.all([
page.waitForNavigation(),
page.click('[data-test="submit-button"]'),
]);Database Integration
Query Database in Tests
import { test, expect } from '@playwright/test';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
test('should create project in database', async ({ page }) => {
// Perform UI action
await page.goto('/projects/new');
await page.fill('[data-test="project-name"]', 'E2E Test Project');
await page.click('[data-test="submit"]');
// Wait for success
await expect(page.locator('[data-test="success-toast"]')).toBeVisible();
// Verify in database
const { data } = await supabase
.from('projects')
.select('*')
.eq('name', 'E2E Test Project')
.single();
expect(data).toBeDefined();
expect(data.name).toBe('E2E Test Project');
});Extract Tokens from Database
test('should accept invitation via magic link', async ({ page }) => {
// Create invitation via UI
await page.goto('/team/members');
await page.click('[data-test="invite-member"]');
await page.fill('[data-test="email-input"]', 'newmember@example.com');
await page.click('[data-test="send-invite"]');
// Get token from database (not visible in UI)
const { data: invitation } = await supabase
.from('invitations')
.select('token')
.eq('email', 'newmember@example.com')
.single();
// Use token to accept invitation
await page.goto(`/join?token=${invitation.token}`);
await expect(page.locator('[data-test="welcome-message"]')).toBeVisible();
});Running E2E Tests
Common Commands
# Run all E2E tests
pnpm --filter e2e test
# Run specific test file
pnpm --filter e2e test authentication/sign-in.spec.ts
# Run with visible browser
pnpm --filter e2e test --headed
# Run in debug mode
pnpm --filter e2e test --debug
# Run with UI mode
pnpm --filter e2e test --ui
# Run specific browser
pnpm --filter e2e test --project=chromium
pnpm --filter e2e test --project=firefox
# Generate test report
pnpm --filter e2e test --reporter=htmlDebugging Failed Tests
# Run with trace enabled
pnpm --filter e2e test --trace on
# View trace
npx playwright show-trace trace.zip
# Run in debug mode (step through)
pnpm --filter e2e test --debug
# Take screenshots on failure (default)
# Screenshots saved to test-results/Best Practices
1. Keep Tests Independent
// ✅ GOOD - Each test is independent
test('should create project', async ({ page }) => {
// Complete setup within test
await login(page);
await createProject(page, 'Test Project');
// Assertions
});
test('should delete project', async ({ page }) => {
// Complete setup within test
await login(page);
const project = await createProject(page, 'To Delete');
await deleteProject(page, project.id);
// Assertions
});
// ❌ BAD - Tests depend on each other
let projectId: string;
test('should create project', async ({ page }) => {
projectId = await createProject(page, 'Test');
});
test('should delete project', async ({ page }) => {
// Depends on previous test
await deleteProject(page, projectId);
});2. Use Serial Mode When Necessary
// For tests that must run in order
test.describe.serial('User Onboarding Flow', () => {
test('step 1: sign up', async ({ page }) => {
// ...
});
test('step 2: verify email', async ({ page }) => {
// ...
});
test('step 3: complete profile', async ({ page }) => {
// ...
});
});3. Clean Up Test Data
test.afterEach(async () => {
// Clean up test data
await supabase
.from('projects')
.delete()
.like('name', 'E2E Test%');
});4. Handle Flaky Tests
// Retry flaky tests
test('potentially flaky test', async ({ page }) => {
test.slow(); // Mark as slow
// Add explicit waits
await page.waitForLoadState('networkidle');
// Use retry assertions
await expect(page.locator('[data-test="data"]'))
.toBeVisible({ timeout: 10000 });
});Next Steps
- Unit Testing - Vitest configuration and patterns
- Property-Based Testing - Using fast-check
- Testing Overview - General testing strategy