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
- Use
data-testordata-testidattributes as your primary locator strategy — they survive refactors. - Page Object Model keeps tests clean and maintainable — locators live in one place, not scattered across test files.
- Custom fixtures eliminate repeated setup code and make tests more readable.
- Always configure retries, traces, and screenshots in CI — debugging a failed CI test without a trace is guesswork.
- Separate UI tests and API tests into different folders for clearer organisation and independent execution.