- Home
- Playwright Automation Testing Tutorial
Playwright
Automation Testing Tutorial
Master end-to-end automation testing with Playwright — from installation to writing real tests, locators, assertions, reports, and professional best practices.
Key facts about Playwright:
What makes Playwright special compared to older tools:
- →Auto-waiting built in Playwright automatically waits for elements to appear, be visible, and be ready before interacting. No more manual sleep() or flaky tests from timing issues.
- →All browsers, one API Write one test and run it on Chrome, Firefox, and Safari without changing any code. Playwright handles the browser differences internally.
- →Modern web support Playwright handles SPAs (React, Angular, Vue), Shadow DOM, iframes, multiple tabs, file uploads, network mocking, and more — out of the box.
- →Codegen tool Record your browser actions and Playwright automatically generates the test code for you. No coding required to create initial test scripts.
- →Trace Viewer When a test fails, the Trace Viewer shows you a step-by-step video replay, screenshots, and DOM snapshots — so you know exactly what went wrong.
| Feature | 🎭 Playwright | 🔵 Selenium |
|---|---|---|
| Released | 2020 — Microsoft | 2004 — Jason Huggins |
| Architecture | Direct browser control (CDP/WebSocket) | WebDriver protocol — extra middleman layer |
| Auto-waiting | ✅ Built-in — no manual waits needed | ❌ Must write explicit waits manually |
| Browser setup | Browsers bundled — one command installs all | Manual driver files needed (chromedriver etc.) |
| Speed | Faster — direct protocol + native parallel | Slower — driver overhead, Grid for parallel |
| Language support | JS/TS, Python, Java, .NET | Java, Python, C#, Ruby, JS, PHP, Perl |
| Multi-tab testing | ✅ Native support | ⚠️ Complex — requires window handle management |
| Network mocking | ✅ Built-in API interception | ❌ No built-in — needs extra tools |
| Trace / Debug | ✅ Trace Viewer with video + DOM snapshots | Screenshots only |
| Legacy browser | ❌ No IE support | ✅ Supports Internet Explorer |
| Best use case | Modern web apps, SPAs, CI/CD pipelines | Enterprise legacy systems, wider language needs |
Prerequisites — make sure these are installed first:
- 1Node.js (v18+) Download from nodejs.org. After installing, verify with: node --version
- 2VS Code (recommended) Best IDE for Playwright. Install the official Playwright Test for VS Code extension by Microsoft for an excellent testing experience.
Step 1 — Create a new project folder and open terminal:
Step 2 — Run the Playwright initializer:
2. Add GitHub Actions workflow? Press Y (yes) — useful for CI/CD later
3. Install Playwright browsers? Press Y (yes) — downloads Chromium, Firefox, WebKit
4. TypeScript or JavaScript? Choose based on your preference. TypeScript recommended for professional projects.
Step 3 — What gets created after initialization:
Step 4 — Verify installation by running example tests:
Headless = Browser runs in the background with no visible window. Faster. Used in CI/CD pipelines.
Headed = Browser window opens and you can watch tests execute visually. Used during development and debugging.
Default is headless. Run headed with: npx playwright test --headed
The playwright.config.ts file — key settings explained:
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', // Folder where test files live timeout: 30000, // Max time for each test (30 seconds) retries: 1, // Retry failed tests once before marking fail reporter: 'html', // Generate HTML report after test run use: { baseURL: 'http://localhost:3000', // Root URL of your app headless: true, // Run without browser window screenshot: 'only-on-failure', // Capture screenshot when test fails video: 'retain-on-failure', // Record video on failure trace: 'on-first-retry', // Capture trace on first retry }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], });
Recommended folder structure for a real project:
Anatomy of a Playwright test:
// 1. Import test and expect from Playwright import { test, expect } from '@playwright/test'; // 2. Declare a test with a descriptive name test('user can login with valid credentials', async ({ page }) => { // 3. Navigate to the login page await page.goto('/login'); // 4. Find the email field and type into it await page.getByLabel('Email').fill('priya@example.com'); // 5. Find the password field and type into it await page.getByLabel('Password').fill('Test@123'); // 6. Click the Login button await page.getByRole('button', { name: 'Login' }).click(); // 7. Verify the dashboard heading is visible after login await expect(page.getByRole('heading', { name: 'Dashboard' })) .toBeVisible(); });
Grouping related tests with test.describe():
import { test, expect } from '@playwright/test'; test.describe('Login Page', () => { test.beforeEach(async ({ page }) => { await page.goto('/login'); // runs before EVERY test in this group }); test('valid login redirects to dashboard', async ({ page }) => { await page.getByLabel('Email').fill('priya@example.com'); await page.getByLabel('Password').fill('Test@123'); await page.getByRole('button', { name: 'Login' }).click(); await expect(page).toHaveURL('/dashboard'); }); test('wrong password shows error message', async ({ page }) => { await page.getByLabel('Email').fill('priya@example.com'); await page.getByLabel('Password').fill('wrongpass'); await page.getByRole('button', { name: 'Login' }).click(); await expect(page.getByText('Invalid credentials')).toBeVisible(); }); });
Locator priority — use in this order:
// Click a button with visible text "Submit" await page.getByRole('button', { name: 'Submit' }).click(); // Click a button — case insensitive match using regex await page.getByRole('button', { name: /submit/i }).click(); // Assert a heading with text "Welcome" is visible await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible(); // Check a checkbox labeled "Accept Terms" await page.getByRole('checkbox', { name: 'Accept Terms' }).check(); // Click a navigation link await page.getByRole('link', { name: 'Home' }).click();
// Fill the Email input field await page.getByLabel('Email').fill('priya@example.com'); // Fill the Password input field await page.getByLabel('Password').fill('Test@123');
// Fill input that shows placeholder "Search products..." await page.getByPlaceholder('Search products...').fill('laptop'); // Fill input with placeholder "Enter your email" await page.getByPlaceholder('Enter your email').fill('test@test.com');
// Assert error message is shown await expect(page.getByText('Invalid credentials')).toBeVisible(); // Click a menu item by its text await page.getByText('My Profile').click(); // Exact match — only matches "Log out", not "Log out now" await page.getByText('Log out', { exact: true }).click();
// HTML: <button data-testid="submit-btn">Submit</button> await page.getByTestId('submit-btn').click();
Most commonly used actions with examples:
// ── CLICK ────────────────────────────────────── await page.getByRole('button', { name: 'Login' }).click(); await page.getByText('Forgot Password?').click(); // ── FILL (type into input) ────────────────────── await page.getByLabel('Username').fill('priya123'); // fill() clears existing text first, then types // ── TYPE (types character by character) ───────── await page.getByLabel('Search').pressSequentially('laptop'); // Triggers keystroke events — needed for autocomplete // ── CLEAR ─────────────────────────────────────── await page.getByLabel('Email').clear(); // ── SELECT DROPDOWN ───────────────────────────── await page.getByRole('combobox').selectOption('India'); await page.getByLabel('Country').selectOption({ value: 'IN' }); // ── CHECKBOX ──────────────────────────────────── await page.getByRole('checkbox', { name: 'Accept Terms' }).check(); await page.getByRole('checkbox', { name: 'Subscribe' }).uncheck(); // ── HOVER ─────────────────────────────────────── await page.getByText('Account').hover(); // Use to open dropdown menus that appear on hover // ── NAVIGATE ──────────────────────────────────── await page.goto('/login'); // relative URL (uses baseURL) await page.goto('https://example.com'); // absolute URL await page.goBack(); // browser back button await page.reload(); // refresh the page // ── KEYBOARD KEYS ─────────────────────────────── await page.getByLabel('Search').press('Enter'); await page.keyboard.press('Escape'); // close modal // ── SCREENSHOT ────────────────────────────────── await page.screenshot({ path: 'screenshot.png' });
fill() directly sets the input value — fast, no keystroke events. Use for most form fields.
pressSequentially() simulates actual keystrokes one by one — use for search boxes, autocomplete fields, or any input that listens for keyboard events to trigger suggestions.
Most important assertions you will use every day:
// ── VISIBILITY ────────────────────────────────── await expect(page.getByText('Welcome, Priya!')).toBeVisible(); await expect(page.getByText('Error')).not.toBeVisible(); // ── URL ───────────────────────────────────────── await expect(page).toHaveURL('/dashboard'); await expect(page).toHaveURL(/dashboard/); // regex match // ── PAGE TITLE ────────────────────────────────── await expect(page).toHaveTitle('My App - Dashboard'); // ── ELEMENT TEXT ──────────────────────────────── await expect(page.getByRole('heading')).toHaveText('Dashboard'); await expect(page.getByTestId('cart-count')).toContainText('3'); // ── INPUT VALUE ───────────────────────────────── await expect(page.getByLabel('Email')).toHaveValue('priya@example.com'); // ── CHECKBOX STATE ────────────────────────────── await expect(page.getByRole('checkbox', { name: 'Terms' })).toBeChecked(); // ── BUTTON/INPUT STATE ────────────────────────── await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled(); // ── COUNT (number of items in a list) ─────────── await expect(page.getByRole('listitem')).toHaveCount(5);
Complete test example — Login with assertions:
test('valid login shows dashboard', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('priya@example.com'); await page.getByLabel('Password').fill('Test@123'); await page.getByRole('button', { name: 'Login' }).click(); // Assert URL changed to dashboard await expect(page).toHaveURL('/dashboard'); // Assert heading is visible await expect(page.getByRole('heading', { name: 'Dashboard' })) .toBeVisible(); // Assert user name appears in the header await expect(page.getByText('Welcome, Priya')).toBeVisible(); });
toHaveText('Cart (3)') — full exact text match (entire element text must equal this)
toContainText('3') — partial match (element text just needs to contain '3')
Use toContainText when you only want to verify part of the text, e.g. verifying a cart count without knowing the full surrounding label.
🎭 Playwright's way: Just write await page.getByRole('button', {name: 'Submit'}).click(). Playwright automatically waits — no extra code needed.
// ✅ PLAYWRIGHT WAY — clean, no manual waits test('add item to cart', async ({ page }) => { await page.goto('/products'); // Playwright waits for button to appear and be clickable await page.getByRole('button', { name: 'Add to Cart' }).click(); // Playwright waits for toast/confirmation to become visible await expect(page.getByText('Added to cart!')).toBeVisible(); }); // ❌ OLD WAY (Selenium-style) — messy, error-prone // await page.waitForSelector('button'); // Don't need this // await page.waitForTimeout(2000); // Never use this
Timeouts — what they are and how to configure:
// Override timeout for one specific test test('slow report generation', async ({ page }) => { test.setTimeout(60000); // give this test 60 seconds await page.getByRole('button', { name: 'Generate Report' }).click(); await expect(page.getByText('Report Ready')) .toBeVisible({ timeout: 50000 }); // wait up to 50s for this element });
Essential CLI commands:
✅ Passed tests — shown in green with execution time
❌ Failed tests — shown in red with full error message, expected vs actual values
📸 Screenshots — automatically captured on failure
🎥 Video recordings — full recording of the test run on failure
🔍 Trace Viewer — step-by-step DOM snapshots + network logs for every action
How to launch Codegen:
• Left: A browser window — interact with your app here
• Right: Playwright Inspector — shows the generated code live
2. As you click, type, and navigate in the browser, code appears on the right automatically.
3. When you're done, copy the generated code into your .spec.ts test file.
4. Important: Codegen gives you a starting point — you still need to add meaningful expect() assertions manually. It records actions but not verifications.
// Auto-generated by Playwright Codegen import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('https://your-app.com/login'); await page.getByLabel('Email').click(); await page.getByLabel('Email').fill('priya@example.com'); await page.getByLabel('Password').fill('Test@123'); await page.getByRole('button', { name: 'Login' }).click(); // ← Add your assertions here manually after recording: await expect(page).toHaveURL('/dashboard'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); });
Other useful Codegen options:
These practices are followed by professional QA automation engineers. Applying them consistently is what separates a clean, maintainable test suite from a fragile, hard-to-maintain one.
- 1Prefer user-facing locators over CSS/XPath Always try getByRole, getByLabel, getByPlaceholder, and getByText first. These locators reflect how real users identify elements and remain stable when developers refactor the UI without changing functionality.
- 2Never use page.waitForTimeout() — use smart assertions instead Hardcoded sleeps like waitForTimeout(3000) make tests slow and brittle. Use await expect(locator).toBeVisible() which retries automatically and succeeds as soon as the element appears.
- 3One test = one user scenario Each test should verify one clear behaviour end-to-end. Tests with 30+ steps that test login, profile update, checkout, and logout all in one test are hard to debug and maintain. Split into focused, independent tests.
- 4Use test.describe() to group related tests and beforeEach() for shared setup If 5 tests all start on the same page, use beforeEach(() => page.goto('/login')) rather than repeating it in every test. Reduces duplication and is easier to update.
- 5Use meaningful, descriptive test names A test named 'test1' tells you nothing when it fails. Name tests like 'user cannot login with expired password' — the name should describe the scenario and expected outcome so any team member understands what broke.
- 6Enable screenshots and video on failure in config Set screenshot: 'only-on-failure' and video: 'retain-on-failure' in playwright.config.ts. This provides instant visual evidence when tests fail in CI — no guessing about what went wrong.
- 7Run tests across all three browsers — Chromium, Firefox, WebKit A test that passes on Chrome may fail on Safari due to browser-specific rendering differences. Cross-browser testing is Playwright's biggest strength — use it. Configure all three projects in playwright.config.ts.
- 8Always use await — never skip it Forgetting await before a Playwright action is the most common beginner mistake. The action runs, the test moves on before it completes, and you get confusing failures. Every single interaction and assertion must be awaited.
- 9Test both positive and negative paths For every feature, write tests for the happy path (valid input → success) AND the error paths (invalid input → correct error message shown). Error handling bugs are extremely common and only caught by negative tests.
- 10Keep tests independent — each test must work alone Tests should never depend on other tests running first. If Test B requires Test A's data, use beforeEach to set up that data fresh. Tests that work only when run in a specific order are a maintenance nightmare.
"I start by running Codegen to record the login flow to get a quick skeleton. I then rename the test descriptively, split it into multiple test cases — valid login, wrong password, empty fields. I use getByLabel for form inputs and getByRole for the button. After clicking login, I use toHaveURL to verify the redirect and toBeVisible to confirm the dashboard heading. I add a negative test asserting the error message appears with wrong credentials. I configure beforeEach to navigate to the login page before each test. Finally, I run the suite on Chromium, Firefox, and WebKit to ensure cross-browser compatibility."
Ready to Master Playwright in Real Projects?
STAD Solution's QA Automation course covers Playwright end-to-end with hands-on practice on real applications, CI/CD integration, Page Object Model, and 100% placement support.
Explore Courses at STAD Solution →