Introduction
Build production-grade Playwright test frameworks with TypeScript, following industry best practices. This comprehensive guide covers architecture, design patterns, configuration, and CI/CD integration.
š” What You'll Learn:
- Modern project structure with TypeScript
- Page Object Model with fixtures pattern
- Custom fixtures for dependency injection
- Configuration management for multiple environments
- Parallel execution and test organization
- Reporting with HTML and Allure
- CI/CD integration (GitHub Actions, GitLab CI)
- Best practices and anti-patterns to avoid
šÆ Framework Goals:
- Type Safety: Full TypeScript for better IDE support
- Scalability: Handle 100s of tests efficiently
- Maintainability: Easy to update and extend
- Speed: Fast execution with parallel testing
- Reliability: Consistent results across environments
1. Project Structure & Setup
Recommended Project Structure
ArchitectureProject Structure
playwright-framework/ āāā tests/ ā āāā auth/ ā ā āāā login.spec.ts ā ā āāā signup.spec.ts ā āāā dashboard/ ā ā āāā widgets.spec.ts ā ā āāā settings.spec.ts ā āāā checkout/ ā āāā payment.spec.ts ā āāā pages/ ā āāā BasePage.ts ā āāā LoginPage.ts ā āāā DashboardPage.ts ā āāā CheckoutPage.ts ā āāā fixtures/ ā āāā test.ts # Custom fixtures ā āāā pages.fixture.ts # Page object fixtures ā āāā utils/ ā āāā helpers.ts ā āāā api-helpers.ts ā āāā test-data.ts ā āāā config/ ā āāā env.config.ts ā āāā test.config.ts ā āāā test-data/ ā āāā users.json ā āāā products.json ā āāā setup/ ā āāā global-setup.ts ā āāā global-teardown.ts ā āāā auth.setup.ts ā āāā playwright.config.ts # Main config āāā .env.example āāā .gitignore āāā package.json āāā tsconfig.json
š” Structure Principles:
- Tests: Organized by feature/domain
- Pages: Page Object classes with TypeScript
- Fixtures: Dependency injection for setup/teardown
- Utils: Reusable helper functions
- Config: Environment-specific configurations
- Setup: Global setup for authentication, database, etc.
Package Setup
Setuppackage.json
{
"name": "playwright-framework",
"version": "1.0.0",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:ui": "playwright test --ui",
"test:chrome": "playwright test --project=chromium",
"test:firefox": "playwright test --project=firefox",
"test:webkit": "playwright test --project=webkit",
"test:mobile": "playwright test --project=mobile",
"test:api": "playwright test tests/api/",
"test:smoke": "playwright test --grep @smoke",
"test:regression": "playwright test --grep @regression",
"report": "playwright show-report",
"codegen": "playwright codegen",
"trace": "playwright show-trace",
"install:browsers": "playwright install --with-deps"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"dotenv": "^16.3.1",
"typescript": "^5.3.3"
}
}tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node", "@playwright/test"],
"baseUrl": ".",
"paths": {
"@pages/*": ["pages/*"],
"@fixtures/*": ["fixtures/*"],
"@utils/*": ["utils/*"],
"@config/*": ["config/*"]
}
},
"include": [
"tests/**/*",
"pages/**/*",
"fixtures/**/*",
"utils/**/*",
"config/**/*",
"setup/**/*"
],
"exclude": ["node_modules", "test-results", "playwright-report"]
}2. Page Object Model with TypeScript
BasePage Implementation
Page ObjectTypeScript - pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export class BasePage {
protected page: Page;
protected baseURL: string;
constructor(page: Page) {
this.page = page;
this.baseURL = process.env.BASE_URL || 'https://example.com';
}
/**
* Navigate to a relative URL
*/
async goto(path: string = '') {
await this.page.goto(`${this.baseURL}${path}`);
}
/**
* Wait for page to be fully loaded
*/
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
/**
* Get page title
*/
async getTitle(): Promise<string> {
return await this.page.title();
}
/**
* Get current URL
*/
getCurrentURL(): string {
return this.page.url();
}
/**
* Take screenshot
*/
async takeScreenshot(name: string) {
await this.page.screenshot({
path: `screenshots/${name}.png`,
fullPage: true
});
}
/**
* Wait for element to be visible
*/
async waitForElement(locator: Locator, timeout: number = 30000) {
await locator.waitFor({ state: 'visible', timeout });
}
/**
* Check if element is visible
*/
async isElementVisible(locator: Locator): Promise<boolean> {
try {
await locator.waitFor({ state: 'visible', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Click element with retry
*/
async clickWithRetry(locator: Locator, maxAttempts: number = 3) {
for (let i = 0; i < maxAttempts; i++) {
try {
await locator.click({ timeout: 5000 });
return;
} catch (error) {
if (i === maxAttempts - 1) throw error;
await this.page.waitForTimeout(1000);
}
}
}
/**
* Fill input with clear
*/
async fillInput(locator: Locator, text: string) {
await locator.clear();
await locator.fill(text);
}
/**
* Get element text
*/
async getElementText(locator: Locator): Promise<string> {
return await locator.textContent() || '';
}
/**
* Press keyboard key
*/
async pressKey(key: string) {
await this.page.keyboard.press(key);
}
}Page Object Example
Page ObjectTypeScript - pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
// Locators
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly loginButton: Locator;
private readonly errorMessage: Locator;
private readonly forgotPasswordLink: Locator;
constructor(page: Page) {
super(page);
// Initialize locators
this.emailInput = page.getByRole('textbox', { name: 'Email' });
this.passwordInput = page.getByRole('textbox', { name: 'Password' });
this.loginButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.locator('[data-testid="error-message"]');
this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot Password?' });
}
/**
* Navigate to login page
*/
async goto() {
await super.goto('/login');
await this.waitForPageLoad();
}
/**
* Enter email
*/
async enterEmail(email: string) {
await this.emailInput.fill(email);
}
/**
* Enter password
*/
async enterPassword(password: string) {
await this.passwordInput.fill(password);
}
/**
* Click login button
*/
async clickLogin() {
await this.loginButton.click();
}
/**
* Complete login flow (method chaining)
*/
async login(email: string, password: string) {
await this.enterEmail(email);
await this.enterPassword(password);
await this.clickLogin();
// Wait for navigation
await this.page.waitForURL('**/dashboard');
}
/**
* Get error message
*/
async getErrorMessage(): Promise<string> {
return await this.errorMessage.textContent() || '';
}
/**
* Check if error is displayed
*/
async isErrorDisplayed(): Promise<boolean> {
return await this.isElementVisible(this.errorMessage);
}
/**
* Click forgot password
*/
async clickForgotPassword() {
await this.forgotPasswordLink.click();
}
/**
* Verify login page is loaded
*/
async isLoaded(): Promise<boolean> {
return await this.isElementVisible(this.loginButton) &&
await this.isElementVisible(this.emailInput);
}
}TypeScript - pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
// Locators
private readonly welcomeMessage: Locator;
private readonly userMenu: Locator;
private readonly settingsLink: Locator;
private readonly logoutButton: Locator;
constructor(page: Page) {
super(page);
this.welcomeMessage = page.locator('[data-testid="welcome-message"]');
this.userMenu = page.getByRole('button', { name: 'User Menu' });
this.settingsLink = page.getByRole('link', { name: 'Settings' });
this.logoutButton = page.getByRole('button', { name: 'Logout' });
}
/**
* Navigate to dashboard
*/
async goto() {
await super.goto('/dashboard');
await this.waitForPageLoad();
}
/**
* Get welcome message text
*/
async getWelcomeMessage(): Promise<string> {
return await this.welcomeMessage.textContent() || '';
}
/**
* Open user menu
*/
async openUserMenu() {
await this.userMenu.click();
}
/**
* Navigate to settings
*/
async goToSettings() {
await this.openUserMenu();
await this.settingsLink.click();
}
/**
* Logout
*/
async logout() {
await this.openUserMenu();
await this.logoutButton.click();
}
/**
* Check if dashboard is loaded
*/
async isLoaded(): Promise<boolean> {
return await this.isElementVisible(this.welcomeMessage);
}
}3. Custom Fixtures for Dependency Injection
Custom Fixtures Implementation
FixturesTypeScript - fixtures/test.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
// Extend basic test with custom fixtures
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: Page;
};
export const test = base.extend<MyFixtures>({
// LoginPage fixture
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
// Automatic cleanup
},
// DashboardPage fixture
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
// Pre-authenticated page fixture
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(
process.env.TEST_USER_EMAIL || 'test@example.com',
process.env.TEST_USER_PASSWORD || 'password123'
);
// Page is now authenticated
await use(page);
// Cleanup: Logout
const dashboardPage = new DashboardPage(page);
await dashboardPage.logout();
}
});
export { expect } from '@playwright/test';TypeScript - Advanced Fixtures
// fixtures/pages.fixture.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { CheckoutPage } from '../pages/CheckoutPage';
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
checkoutPage: CheckoutPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
}
});
// fixtures/api.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
type APIFixtures = {
apiContext: APIRequestContext;
};
export const test = base.extend<APIFixtures>({
apiContext: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: process.env.API_URL || 'https://api.example.com',
extraHTTPHeaders: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
await use(context);
await context.dispose();
}
});TypeScript - Using Custom Fixtures
import { test, expect } from '../fixtures/test';
test.describe('Login Tests', () => {
test('successful login with fixtures', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(dashboardPage.isLoaded()).toBeTruthy();
});
test('test with authenticated page', async ({ authenticatedPage }) => {
// Already logged in!
await authenticatedPage.goto('/settings');
await expect(authenticatedPage.getByRole('heading'))
.toHaveText('Settings');
});
});4. Configuration Management
Playwright Config
ConfigurationTypeScript - playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
export default defineConfig({
// Test directory
testDir: './tests',
// Test match pattern
testMatch: '**/*.spec.ts',
// Timeout settings
timeout: 60000,
expect: {
timeout: 10000
},
// Global setup/teardown
globalSetup: require.resolve('./setup/global-setup'),
globalTeardown: require.resolve('./setup/global-teardown'),
// Run tests in parallel
fullyParallel: true,
// Retry on failure
retries: process.env.CI ? 2 : 0,
// Number of workers
workers: process.env.CI ? 2 : undefined,
// Fail fast
maxFailures: process.env.CI ? 10 : undefined,
// Reporter configuration
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['list']
],
// Shared settings for all projects
use: {
// Base URL
baseURL: process.env.BASE_URL || 'https://example.com',
// Browser settings
headless: process.env.HEADED !== 'true',
viewport: { width: 1280, height: 720 },
// Screenshots and videos
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
// Timeouts
navigationTimeout: 30000,
actionTimeout: 10000,
// Locale and timezone
locale: 'en-US',
timezoneId: 'America/New_York',
// Ignore HTTPS errors
ignoreHTTPSErrors: true,
},
// Projects for different browsers
projects: [
// Setup project
{
name: 'setup',
testMatch: /.*.setup.ts/
},
// Chromium
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
},
// Firefox
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
},
// WebKit
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
},
// Mobile Chrome
{
name: 'mobile',
use: {
...devices['Pixel 5'],
storageState: 'playwright/.auth/user.json'
},
dependencies: ['setup']
}
],
// Web server (optional - start dev server)
// webServer: {
// command: 'npm run start',
// port: 3000,
// reuseExistingServer: !process.env.CI,
// timeout: 120000
// }
});Environment Configuration
ConfigurationTypeScript - config/env.config.ts
export class EnvConfig {
static getBaseURL(): string {
const env = process.env.ENV || 'qa';
const urls = {
dev: 'https://dev.example.com',
qa: 'https://qa.example.com',
staging: 'https://staging.example.com',
prod: 'https://www.example.com'
};
return urls[env as keyof typeof urls] || urls.qa;
}
static getAPIURL(): string {
const env = process.env.ENV || 'qa';
const urls = {
dev: 'https://api-dev.example.com',
qa: 'https://api-qa.example.com',
staging: 'https://api-staging.example.com',
prod: 'https://api.example.com'
};
return urls[env as keyof typeof urls] || urls.qa;
}
static getTestUserEmail(): string {
return process.env.TEST_USER_EMAIL || 'test@example.com';
}
static getTestUserPassword(): string {
return process.env.TEST_USER_PASSWORD || 'password123';
}
static isHeadless(): boolean {
return process.env.HEADED !== 'true';
}
static getTimeout(): number {
return parseInt(process.env.TIMEOUT || '30000');
}
}.env.example
# Environment ENV=qa # URLs BASE_URL=https://qa.example.com API_URL=https://api-qa.example.com # Test Credentials TEST_USER_EMAIL=test@example.com TEST_USER_PASSWORD=password123 # Browser Settings HEADED=false TIMEOUT=30000 # CI/CD CI=false
5. Authentication State Management
Global Authentication Setup
SetupTypeScript - setup/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
const loginPage = new LoginPage(page);
// Navigate to login page
await loginPage.goto();
// Perform login
await loginPage.login(
process.env.TEST_USER_EMAIL || 'test@example.com',
process.env.TEST_USER_PASSWORD || 'password123'
);
// Wait for successful login
await page.waitForURL('**/dashboard');
// Verify login success
await expect(page.getByRole('heading')).toContainText('Dashboard');
// Save authentication state
await page.context().storageState({ path: authFile });
console.log('ā
Authentication state saved');
});TypeScript - Multiple Users
// setup/auth.setup.ts - Extended
const adminAuthFile = path.join(__dirname, '../playwright/.auth/admin.json');
const userAuthFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate as admin', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@example.com', 'admin123');
await page.waitForURL('**/admin');
await page.context().storageState({ path: adminAuthFile });
});
setup('authenticate as user', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'user123');
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: userAuthFile });
});6. Test Organization & Data-Driven Testing
Test Structure & Organization
Test OrganizationTypeScript - tests/auth/login.spec.ts
import { test, expect } from '../../fixtures/test';
test.describe('Login Functionality', () => {
// Run before each test in this describe block
test.beforeEach(async ({ loginPage }) => {
await loginPage.goto();
});
test('successful login with valid credentials @smoke', async ({ loginPage, dashboardPage }) => {
await loginPage.login('user@example.com', 'password123');
await expect(dashboardPage.isLoaded()).toBeTruthy();
const welcomeMsg = await dashboardPage.getWelcomeMessage();
expect(welcomeMsg).toContain('Welcome');
});
test('failed login with invalid credentials @regression', async ({ loginPage }) => {
await loginPage.login('invalid@example.com', 'wrongpassword');
expect(await loginPage.isErrorDisplayed()).toBeTruthy();
const errorMsg = await loginPage.getErrorMessage();
expect(errorMsg).toContain('Invalid credentials');
});
test('login with empty email @regression', async ({ loginPage }) => {
await loginPage.enterPassword('password123');
await loginPage.clickLogin();
expect(await loginPage.isErrorDisplayed()).toBeTruthy();
});
test('login with empty password @regression', async ({ loginPage }) => {
await loginPage.enterEmail('user@example.com');
await loginPage.clickLogin();
expect(await loginPage.isErrorDisplayed()).toBeTruthy();
});
});
// Skip tests conditionally
test.describe('Browser-specific tests', () => {
test('webkit only feature', async ({ page, browserName }) => {
test.skip(browserName !== 'webkit', 'Safari-only feature');
// Test Safari-specific functionality
});
});
// Serial execution (tests run in order)
test.describe.serial('Dependent tests', () => {
let userId: string;
test('create user', async ({ page }) => {
// Create user and get ID
userId = '123';
});
test('update user', async ({ page }) => {
// Use userId from previous test
console.log(userId);
});
});
// Parallel execution (default)
test.describe.parallel('Independent tests', () => {
test('test 1', async ({ page }) => {
// Runs in parallel
});
test('test 2', async ({ page }) => {
// Runs in parallel
});
});Data-Driven Testing
Test DataJSON - test-data/users.json
{
"validUsers": [
{
"email": "user1@example.com",
"password": "password123",
"expectedName": "John Doe"
},
{
"email": "user2@example.com",
"password": "password456",
"expectedName": "Jane Smith"
}
],
"invalidUsers": [
{
"email": "invalid@example.com",
"password": "wrongpass",
"expectedError": "Invalid credentials"
},
{
"email": "notfound@example.com",
"password": "password",
"expectedError": "User not found"
}
]
}TypeScript - Data-Driven Test
import { test, expect } from '../../fixtures/test';
import users from '../../test-data/users.json';
test.describe('Login with multiple users', () => {
// Loop through test data
for (const user of users.validUsers) {
test(`login as ${user.email}`, async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login(user.email, user.password);
const welcomeMsg = await dashboardPage.getWelcomeMessage();
expect(welcomeMsg).toContain(user.expectedName);
});
}
for (const user of users.invalidUsers) {
test(`failed login: ${user.email}`, async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login(user.email, user.password);
const errorMsg = await loginPage.getErrorMessage();
expect(errorMsg).toContain(user.expectedError);
});
}
});
// Using test.describe.configure for each
test.describe('Parameterized tests', () => {
const testCases = [
{ input: 'test@example.com', expected: true },
{ input: 'invalid-email', expected: false },
{ input: '', expected: false }
];
for (const testCase of testCases) {
test(`validate email: ${testCase.input}`, async ({ page }) => {
// Test logic here
});
}
});TypeScript - Test Data Helper
// utils/test-data.ts
import { faker } from '@faker-js/faker';
export class TestDataGenerator {
static generateUser() {
return {
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
phone: faker.phone.number()
};
}
static generateAddress() {
return {
street: faker.location.streetAddress(),
city: faker.location.city(),
state: faker.location.state(),
zipCode: faker.location.zipCode(),
country: faker.location.country()
};
}
static generateCreditCard() {
return {
number: '4111111111111111', // Test card
cvv: faker.finance.creditCardCVV(),
expiryMonth: '12',
expiryYear: '2025'
};
}
}
// Usage
const user = TestDataGenerator.generateUser();7. API Testing & Hybrid Tests
API Helper Class
API TestingTypeScript - utils/api-helpers.ts
import { APIRequestContext } from '@playwright/test';
export class APIHelper {
private request: APIRequestContext;
private baseURL: string;
constructor(request: APIRequestContext) {
this.request = request;
this.baseURL = process.env.API_URL || 'https://api.example.com';
}
/**
* Create user via API
*/
async createUser(userData: any) {
const response = await this.request.post(`${this.baseURL}/users`, {
data: userData
});
if (!response.ok()) {
throw new Error(`Failed to create user: ${response.status()}`);
}
return await response.json();
}
/**
* Get user by ID
*/
async getUser(userId: string) {
const response = await this.request.get(`${this.baseURL}/users/${userId}`);
if (!response.ok()) {
throw new Error(`Failed to get user: ${response.status()}`);
}
return await response.json();
}
/**
* Update user
*/
async updateUser(userId: string, updates: any) {
const response = await this.request.put(`${this.baseURL}/users/${userId}`, {
data: updates
});
if (!response.ok()) {
throw new Error(`Failed to update user: ${response.status()}`);
}
return await response.json();
}
/**
* Delete user
*/
async deleteUser(userId: string) {
const response = await this.request.delete(`${this.baseURL}/users/${userId}`);
if (!response.ok() && response.status() !== 204) {
throw new Error(`Failed to delete user: ${response.status()}`);
}
}
/**
* Create product (example)
*/
async createProduct(productData: any) {
const response = await this.request.post(`${this.baseURL}/products`, {
data: productData
});
return await response.json();
}
/**
* Set authentication token
*/
async setAuthToken(token: string) {
await this.request.dispose();
// Create new context with token
}
}TypeScript - Hybrid Test Example
import { test, expect } from '../fixtures/test';
import { APIHelper } from '../utils/api-helpers';
import { TestDataGenerator } from '../utils/test-data';
test.describe('User Management - Hybrid Test', () => {
let apiHelper: APIHelper;
let createdUserId: string;
test.beforeEach(async ({ request }) => {
apiHelper = new APIHelper(request);
});
test('create user via API, verify in UI', async ({ page, request }) => {
// SETUP: Create user via API (fast)
const userData = TestDataGenerator.generateUser();
const response = await apiHelper.createUser(userData);
createdUserId = response.id;
// TEST: Verify in UI
await page.goto(`/users/${createdUserId}`);
await expect(page.getByRole('heading')).toHaveText(
`${userData.firstName} ${userData.lastName}`
);
await expect(page.locator('[data-testid="email"]')).toHaveText(userData.email);
});
test('update user in UI, verify via API', async ({ page, request }) => {
// SETUP: Create user via API
const userData = TestDataGenerator.generateUser();
const user = await apiHelper.createUser(userData);
createdUserId = user.id;
// TEST: Update in UI
await page.goto(`/users/${user.id}/edit`);
await page.fill('input[name="firstName"]', 'Updated Name');
await page.click('button[type="submit"]');
// VERIFY: Check via API
const updatedUser = await apiHelper.getUser(user.id);
expect(updatedUser.firstName).toBe('Updated Name');
});
test.afterEach(async () => {
// CLEANUP: Delete user via API
if (createdUserId) {
await apiHelper.deleteUser(createdUserId);
}
});
});
// Pure API test
test.describe('API Tests', () => {
test('CRUD operations via API', async ({ request }) => {
const apiHelper = new APIHelper(request);
// CREATE
const userData = TestDataGenerator.generateUser();
const created = await apiHelper.createUser(userData);
expect(created.id).toBeDefined();
// READ
const retrieved = await apiHelper.getUser(created.id);
expect(retrieved.email).toBe(userData.email);
// UPDATE
const updated = await apiHelper.updateUser(created.id, {
firstName: 'Updated'
});
expect(updated.firstName).toBe('Updated');
// DELETE
await apiHelper.deleteUser(created.id);
});
});8. Reporting & Logging
Custom Reporter
ReportingTypeScript - reporters/custom-reporter.ts
import { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
class CustomReporter implements Reporter {
private passedTests = 0;
private failedTests = 0;
private skippedTests = 0;
private startTime: number = 0;
onBegin() {
this.startTime = Date.now();
console.log('\nš Test execution started\n');
}
onTestEnd(test: TestCase, result: TestResult) {
const duration = (result.duration / 1000).toFixed(2);
if (result.status === 'passed') {
this.passedTests++;
console.log(`ā
PASSED: ${test.title} (${duration}s)`);
} else if (result.status === 'failed') {
this.failedTests++;
console.log(`ā FAILED: ${test.title} (${duration}s)`);
console.log(` Error: ${result.error?.message}`);
} else if (result.status === 'skipped') {
this.skippedTests++;
console.log(`āļø SKIPPED: ${test.title}`);
}
}
onEnd() {
const totalDuration = ((Date.now() - this.startTime) / 1000).toFixed(2);
const total = this.passedTests + this.failedTests + this.skippedTests;
console.log('\n' + '='.repeat(60));
console.log('š Test Execution Summary');
console.log('='.repeat(60));
console.log(`Total Tests: ${total}`);
console.log(`ā
Passed: ${this.passedTests}`);
console.log(`ā Failed: ${this.failedTests}`);
console.log(`āļø Skipped: ${this.skippedTests}`);
console.log(`ā±ļø Duration: ${totalDuration}s`);
console.log('='.repeat(60) + '\n');
}
}
export default CustomReporter;TypeScript - Add to Config
// playwright.config.ts
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['./reporters/custom-reporter.ts'],
['list']
]TypeScript - Logger Utility
// utils/logger.ts
import fs from 'fs';
import path from 'path';
export class Logger {
private logFile: string;
constructor() {
const timestamp = new Date().toISOString().replace(/:/g, '-');
this.logFile = path.join('logs', `test-${timestamp}.log`);
// Ensure logs directory exists
if (!fs.existsSync('logs')) {
fs.mkdirSync('logs');
}
}
info(message: string) {
this.log('INFO', message);
}
error(message: string) {
this.log('ERROR', message);
}
warn(message: string) {
this.log('WARN', message);
}
debug(message: string) {
this.log('DEBUG', message);
}
private log(level: string, message: string) {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level}] ${message}\n`;
// Console output
console.log(logMessage.trim());
// File output
fs.appendFileSync(this.logFile, logMessage);
}
}
// Singleton instance
export const logger = new Logger();9. CI/CD Integration
GitHub Actions
CI/CD.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
schedule:
- cron: '0 0 * * *' # Daily at midnight
workflow_dispatch:
inputs:
environment:
description: 'Environment to test'
required: true
default: 'qa'
type: choice
options:
- dev
- qa
- staging
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Create .env file
run: |
echo "ENV=${{ github.event.inputs.environment || 'qa' }}" >> .env
echo "BASE_URL=${{ secrets.BASE_URL }}" >> .env
echo "API_URL=${{ secrets.API_URL }}" >> .env
echo "TEST_USER_EMAIL=${{ secrets.TEST_USER_EMAIL }}" >> .env
echo "TEST_USER_PASSWORD=${{ secrets.TEST_USER_PASSWORD }}" >> .env
- name: Run Playwright tests
run: npx playwright test --project=${{ matrix.browser }}
env:
CI: true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
- name: Upload test traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces-${{ matrix.browser }}
path: test-results/
retention-days: 7
- name: Publish test results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: test-results/junit.xml
check_name: Test Results (${{ matrix.browser }})
- name: Comment PR with results
if: github.event_name == 'pull_request' && always()
uses: daun/playwright-report-comment@v3
with:
report-url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${{ github.run_id }}
deploy-report:
if: always()
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-reports
- name: Merge reports
run: |
mkdir -p merged-report
cp -r all-reports/**/. merged-report/
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./merged-report
destination_dir: ${{ github.run_id }}GitLab CI
CI/CD.gitlab-ci.yml
image: mcr.microsoft.com/playwright:v1.40.0-jammy
stages:
- test
- report
variables:
ENV: "qa"
npm_config_cache: "$CI_PROJECT_DIR/.npm"
cache:
paths:
- .npm
- node_modules
before_script:
- npm ci
- npx playwright install
test:chromium:
stage: test
script:
- npx playwright test --project=chromium
artifacts:
when: always
paths:
- playwright-report/
- test-results/
expire_in: 1 week
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
test:firefox:
stage: test
script:
- npx playwright test --project=firefox
artifacts:
when: always
paths:
- playwright-report/
- test-results/
expire_in: 1 week
test:webkit:
stage: test
script:
- npx playwright test --project=webkit
artifacts:
when: always
paths:
- playwright-report/
- test-results/
expire_in: 1 week
pages:
stage: report
dependencies:
- test:chromium
- test:firefox
- test:webkit
script:
- mkdir -p public
- cp -r playwright-report/* public/
artifacts:
paths:
- public
only:
- mainDocker Support
ContainerizationDockerfile
FROM mcr.microsoft.com/playwright:v1.40.0-jammy WORKDIR /app # Copy package files COPY package*.json ./ # Install dependencies RUN npm ci # Copy project files COPY . . # Run tests CMD ["npx", "playwright", "test"]
docker-compose.yml
version: '3.8'
services:
playwright-tests:
build: .
environment:
- ENV=qa
- CI=true
volumes:
- ./playwright-report:/app/playwright-report
- ./test-results:/app/test-results
command: npx playwright test
playwright-ui:
build: .
ports:
- "9323:9323"
environment:
- ENV=qa
volumes:
- .:/app
command: npx playwright test --ui-host=0.0.0.010. Best Practices & Anti-patterns
Framework Best Practices
Best Practices| Category | ā Best Practice | ā Anti-pattern |
|---|---|---|
| Locators | Use getByRole(), getByTestId() | Use CSS/XPath based on structure |
| Waits | Use auto-waiting locators | Use page.waitForTimeout() |
| Assertions | Use web-first assertions | Get value then assert (no retry) |
| Page Objects | Use fixtures for dependency injection | Manually instantiate in tests |
| Test Data | Use API for setup/teardown | Use UI for test data creation |
| Authentication | Save and reuse auth state | Login before every test |
| TypeScript | Use strict typing, interfaces | Use 'any' type everywhere |
| Organization | Independent, isolated tests | Tests depend on each other |
Common Anti-patterns to Avoid
Anti-patternsā Anti-pattern #1: Not Using Auto-Waiting
// ā BAD: Manual waits
await page.waitForSelector('.content');
await page.click('.content');
// ā
GOOD: Auto-waiting
await page.locator('.content').click();ā Anti-pattern #2: Not Using Fixtures
// ā BAD: Manual instantiation
test('bad example', async ({ page }) => {
const loginPage = new LoginPage(page);
// ...
});
// ā
GOOD: Use fixtures
test('good example', async ({ loginPage }) => {
// Already instantiated and ready
});ā Anti-pattern #3: Using UI for Test Data Setup
// ā BAD: Create data via UI (slow)
test('bad setup', async ({ page }) => {
await page.goto('/admin/users');
await page.click('button.add-user');
await page.fill('input[name="email"]', 'test@example.com');
await page.click('button.save');
// Now test actual functionality
});
// ā
GOOD: Create data via API (fast)
test('good setup', async ({ page, request }) => {
const user = await request.post('/api/users', {
data: { email: 'test@example.com' }
});
// Test with existing user
await page.goto(`/users/${user.id}`);
});ā Anti-pattern #4: Test Interdependence
// ā BAD: Dependent tests
test.describe.serial('bad tests', () => {
test('create user', async () => {
// Creates user, leaves browser in specific state
});
test('edit user', async () => {
// Assumes user exists from previous test
});
});
// ā
GOOD: Independent tests
test('create user', async ({ page, request }) => {
// Setup, execute, verify, cleanup all in one test
});
test('edit user', async ({ page, request }) => {
// Create user via API, test edit, cleanup
});Code Quality Checklist
Qualityā
Framework Quality Checklist:
- ā All code uses TypeScript with strict mode
- ā Page Objects use dependency injection via fixtures
- ā Tests are independent and can run in any order
- ā Authentication state is saved and reused
- ā Test data setup uses API (not UI)
- ā Locators use getByRole() or data-testid
- ā Web-first assertions (auto-retry) are used
- ā No page.waitForTimeout() in tests
- ā Parallel execution enabled
- ā CI/CD pipeline configured
- ā Reports generated automatically
- ā Environment-specific configs
š” Framework Maintenance:
- Version Control: Use Git with meaningful commits
- Code Reviews: All changes peer-reviewed
- Documentation: Maintain README with setup instructions
- Dependencies: Keep Playwright updated
- Monitoring: Track flaky tests and execution trends
- Refactoring: Regular code cleanup
Framework Implementation Checklist
ā
Complete Framework Components:
Project Setup
- ā TypeScript configuration with strict mode
- ā Playwright config with multiple projects
- ā Environment-specific configurations
- ā Package.json with useful scripts
Page Objects
- ā BasePage with common methods
- ā Page-specific classes extending BasePage
- ā Type-safe locators and methods
- ā No assertions in page objects
Fixtures & Tests
- ā Custom fixtures for page objects
- ā Authentication state management
- ā Tests organized by feature
- ā Data-driven testing setup
API & Utilities
- ā API helper for hybrid testing
- ā Test data generators
- ā Logger utility
- ā Custom reporters
CI/CD
- ā GitHub Actions / GitLab CI pipeline
- ā Docker support
- ā Automated reporting
- ā Parallel execution
š You're Ready to Build Production Frameworks!
This comprehensive guide covered everything from project setup to CI/CD integration. You now have the knowledge to build enterprise-grade Playwright test frameworks with TypeScript.
š Next Steps:
- Implement the framework structure in your project
- Convert existing tests to use Page Objects and fixtures
- Set up CI/CD pipeline for automated execution
- Add API testing for faster test data setup
- Monitor and optimize test execution time