Playwright has become the gold standard for browser automation. It is fast, reliable, supports multiple browsers natively, and has an exceptional TypeScript experience. But most tutorials get you to "hello world" and leave you to figure out the rest. This guide takes you from zero to a production-ready framework.

Step 1 — Installation

terminal
# Create project and initialise Playwright
mkdir my-playwright-suite && cd my-playwright-suite
npm init playwright@latest

# Choose: TypeScript, tests/ folder, GitHub Actions: Yes
# Install browsers
npx playwright install chromium firefox

Step 2 — Project Structure

The default Playwright setup gives you a flat structure. For a real project, you want separation of concerns:

project structure
my-playwright-suite/
├── tests/
│   ├── ui/              # E2E UI tests
│   │   ├── login.spec.ts
│   │   ├── checkout.spec.ts
│   │   └── inventory.spec.ts
│   └── api/             # API tests
│       ├── auth.spec.ts
│       └── products.spec.ts
├── pages/               # Page Object Models
│   ├── BasePage.ts
│   ├── LoginPage.ts
│   ├── InventoryPage.ts
│   └── CheckoutPage.ts
├── fixtures/            # Custom test fixtures
│   └── base.fixture.ts
├── utils/               # Helpers and test data
│   ├── test-data.ts
│   └── api-helpers.ts
└── playwright.config.ts

Step 3 — Configure playwright.config.ts

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 ? 4 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['list'],
  ],
  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'] } },
  ],
});

Step 4 — Build the Base Page Object

The Page Object Model (POM) pattern wraps each page of your application in a class that encapsulates all interactions and locators. Start with a BasePage that all other pages extend:

pages/BasePage.ts
import { Page, Locator } from '@playwright/test';

export class BasePage {
  readonly page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  async navigate(path: string = '/') {
    await this.page.goto(path);
  }

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  async getTitle(): Promise<string> {
    return this.page.title();
  }
}

Step 5 — Create a Page Object (LoginPage example)

pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class LoginPage extends BasePage {
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    super(page);
    this.usernameInput = page.locator('[data-test="username"]');
    this.passwordInput = page.locator('[data-test="password"]');
    this.loginButton   = page.locator('[data-test="login-button"]');
    this.errorMessage  = page.locator('[data-test="error"]');
  }

  async login(username: string, password: string) {
    await this.navigate('/');
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async getErrorMessage(): Promise<string> {
    return this.errorMessage.textContent() ?? '';
  }
}

Step 6 — Write a Test Using the POM

tests/ui/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../pages/LoginPage';
import { TEST_USERS } from '../../utils/test-data';

test.describe('Login', () => {
  test('valid user can log in successfully', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.login(TEST_USERS.standard.username, TEST_USERS.standard.password);
    await expect(page).toHaveURL(/inventory/);
  });

  test('invalid credentials show error message', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.login('wrong_user', 'wrong_pass');
    const error = await loginPage.getErrorMessage();
    expect(error).toContain('Username and password do not match');
  });
});

Step 7 — Custom Fixtures for Shared State

Fixtures let you share pre-authenticated state, pre-seeded data, or shared page objects across multiple tests without repeating setup code:

fixtures/base.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { InventoryPage } from '../pages/InventoryPage';

type MyFixtures = {
  loginPage: LoginPage;
  inventoryPage: InventoryPage;
  authenticatedPage: InventoryPage;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.login('standard_user', 'secret_sauce');
    await use(new InventoryPage(page));
  },
});
export { expect } from '@playwright/test';
Run your suite: npx playwright test — then open the HTML report with npx playwright show-report to see detailed results, screenshots, and traces for any failures.

// Key Takeaways