What is the best way to organize and structure Playwright test suites?
Organizing and structuring Playwright test suites effectively is crucial for maintaining scalable, readable, and maintainable test automation projects. A well-organized test suite reduces maintenance overhead, improves test reliability, and makes it easier for teams to collaborate on test development.
Recommended Project Structure
Here's a comprehensive folder structure that follows industry best practices:
tests/
├── config/
│ ├── playwright.config.ts
│ ├── test-data/
│ │ ├── users.json
│ │ └── api-endpoints.json
│ └── environments/
│ ├── dev.config.ts
│ ├── staging.config.ts
│ └── prod.config.ts
├── fixtures/
│ ├── auth.fixture.ts
│ ├── api.fixture.ts
│ └── database.fixture.ts
├── page-objects/
│ ├── base/
│ │ └── BasePage.ts
│ ├── auth/
│ │ ├── LoginPage.ts
│ │ └── SignupPage.ts
│ └── dashboard/
│ ├── DashboardPage.ts
│ └── ProfilePage.ts
├── utils/
│ ├── helpers.ts
│ ├── constants.ts
│ └── data-generators.ts
├── tests/
│ ├── e2e/
│ │ ├── auth/
│ │ │ ├── login.spec.ts
│ │ │ └── signup.spec.ts
│ │ └── dashboard/
│ │ └── user-profile.spec.ts
│ ├── api/
│ │ ├── users.spec.ts
│ │ └── products.spec.ts
│ └── visual/
│ └── homepage.spec.ts
└── reports/
└── test-results/
Configuration Management
Main Configuration File
Create a centralized playwright.config.ts
file to manage global settings:
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'],
['json', { outputFile: 'reports/test-results.json' }],
['junit', { outputFile: 'reports/junit.xml' }]
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
Environment-Specific Configurations
Create separate configuration files for different environments:
// config/environments/staging.config.ts
import { defineConfig } from '@playwright/test';
import baseConfig from '../playwright.config';
export default defineConfig({
...baseConfig,
use: {
...baseConfig.use,
baseURL: 'https://staging.example.com',
},
retries: 3,
workers: 2,
});
Page Object Model Implementation
Base Page Class
Create a base page class to share common functionality:
// page-objects/base/BasePage.ts
import { Page, Locator } from '@playwright/test';
export class BasePage {
readonly page: Page;
readonly loadingSpinner: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.loadingSpinner = page.locator('[data-testid="loading-spinner"]');
this.errorMessage = page.locator('[data-testid="error-message"]');
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
await this.loadingSpinner.waitFor({ state: 'hidden' });
}
async getErrorMessage(): Promise<string> {
await this.errorMessage.waitFor({ state: 'visible' });
return await this.errorMessage.textContent() || '';
}
}
Feature-Specific Page Objects
Organize page objects by feature or application section:
// page-objects/auth/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from '../base/BasePage';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.locator('[data-testid="email-input"]');
this.passwordInput = page.locator('[data-testid="password-input"]');
this.loginButton = page.locator('[data-testid="login-button"]');
this.forgotPasswordLink = page.locator('[data-testid="forgot-password"]');
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
await this.waitForPageLoad();
}
async isLoginFormVisible(): Promise<boolean> {
return await this.emailInput.isVisible() &&
await this.passwordInput.isVisible() &&
await this.loginButton.isVisible();
}
}
Test Organization Patterns
Grouping by Feature
Organize tests by application features rather than technical layers:
// tests/e2e/auth/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../../page-objects/auth/LoginPage';
import { DashboardPage } from '../../../page-objects/dashboard/DashboardPage';
test.describe('User Authentication', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.login('user@example.com', 'password123');
await expect(dashboardPage.welcomeMessage).toBeVisible();
await expect(page).toHaveURL(/dashboard/);
});
test('should display error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('invalid@example.com', 'wrongpassword');
const errorMessage = await loginPage.getErrorMessage();
expect(errorMessage).toContain('Invalid credentials');
});
});
Test Data Management
Create reusable test data and utilities:
// utils/data-generators.ts
export const TestData = {
users: {
validUser: {
email: 'test@example.com',
password: 'SecurePassword123!',
firstName: 'John',
lastName: 'Doe'
},
invalidUser: {
email: 'invalid@example.com',
password: 'wrongpassword'
}
},
generateRandomUser: () => ({
email: `test${Date.now()}@example.com`,
password: 'TempPassword123!',
firstName: 'Test',
lastName: 'User'
})
};
Fixtures and Setup
Custom Fixtures
Create reusable fixtures for common setup tasks:
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../page-objects/auth/LoginPage';
import { TestData } from '../utils/data-generators';
export const test = base.extend<{
authenticatedPage: any;
loginPage: LoginPage;
}>({
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await page.goto('/login');
await loginPage.login(TestData.users.validUser.email, TestData.users.validUser.password);
await use(page);
},
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
}
});
Database and API Fixtures
For comprehensive testing, create fixtures for database and API setup:
// fixtures/database.fixture.ts
import { test as base } from '@playwright/test';
import { DatabaseHelper } from '../utils/database-helper';
export const test = base.extend<{
cleanDatabase: void;
}>({
cleanDatabase: [async ({}, use) => {
// Setup: Clean database before test
await DatabaseHelper.cleanTestData();
await use();
// Teardown: Clean database after test
await DatabaseHelper.cleanTestData();
}, { auto: true }]
});
Advanced Organization Strategies
Test Categories and Tags
Use test tags to organize and filter tests:
test.describe('E2E Tests', () => {
test('critical user flow @smoke @critical', async ({ page }) => {
// Critical path test
});
test('edge case scenario @regression', async ({ page }) => {
// Edge case test
});
test('performance validation @performance', async ({ page }) => {
// Performance test
});
});
Parallel Test Execution
Structure tests to run efficiently in parallel:
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : 2,
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
],
});
Running Tests with Different Configurations
Command Line Examples
Execute tests with different configurations:
# Run all tests
npx playwright test
# Run tests in specific browser
npx playwright test --project=chromium
# Run tests with tags
npx playwright test --grep "@smoke"
# Run tests in specific environment
npx playwright test --config=config/environments/staging.config.ts
# Run tests in headed mode for debugging
npx playwright test --headed
# Run tests with UI mode
npx playwright test --ui
CI/CD Integration
Configure your CI/CD pipeline to run tests efficiently:
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
project: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run tests
run: npx playwright test --project=${{ matrix.project }}
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.project }}
path: playwright-report/
Best Practices Summary
- Separation of Concerns: Keep page objects, test data, and test logic separate
- Reusability: Create fixtures and utilities for common operations
- Maintainability: Use descriptive naming and consistent patterns
- Scalability: Structure tests to handle growth in team size and test volume
- Environment Management: Support multiple test environments with separate configurations
- Reporting: Implement comprehensive reporting for test results and failures
When implementing web scraping solutions, similar organizational principles apply. Just as handling dynamic content that loads after page navigation requires careful consideration of timing and element states, organizing your test automation requires thoughtful planning of structure and dependencies.
For teams working with both Playwright and other automation tools, understanding different browser contexts and when to use them becomes crucial for creating isolated, reliable test environments.
This structured approach ensures your Playwright test suites remain maintainable, scalable, and reliable as your application and team grow. The key is to establish these patterns early and consistently apply them across your entire test automation project.