Top 100 Playwright
Interview Questions & Answers
Real examples, step-by-step code, and scenario-based questions. prepared for both freshers and experienced professionals.
Playwright is an open-source test automation tool built by Microsoft. It lets you write code that automatically opens a browser and tests your web application — exactly like a real user clicking, typing, and navigating.
Why teams use Playwright:
- Supports all major browsers — Chrome, Firefox, Safari — with one test
- Faster and more reliable than older tools like Selenium
- Handles modern web features well — popups, file uploads, dynamic content
- Supports JavaScript, TypeScript, Python, Java, and C#
Both Playwright and Selenium are used for browser automation, but Playwright is the more modern and faster option.
| Feature | Playwright | Selenium |
|---|---|---|
| Created by | Microsoft (2020) | ThoughtWorks (2004) |
| Speed | Faster — built-in auto-wait | Slower — manual waits needed |
| Setup | Simple — one command install | Complex — separate driver per browser |
| API Testing | Yes — built-in | No — needs extra library |
| Mobile Testing | Device emulation built-in | Needs Appium separately |
| Parallel Execution | Very easy — built-in | Needs Selenium Grid setup |
Playwright supports 4 programming languages. You only need to know one to get started:
- JavaScript / TypeScript — most popular with Playwright, best job demand, most tutorials available
- Python — good if your team already works in Python
- Java — preferred in companies already using Java-based frameworks
- C# (.NET) — used in Microsoft-stack companies
Installing Playwright takes less than 2 minutes. You need Node.js installed first. Then follow these steps:
# Step 1: Create a new project folder
mkdir my-playwright-project
cd my-playwright-project
# Step 2: Install Playwright (one command does everything)
npm init playwright@latest
# It will ask you a few questions:
# TypeScript or JavaScript? → Choose JavaScript
# Where to put tests? → Press Enter (default: tests/)
# Install browsers? → Yes (downloads Chrome, Firefox, Safari)
After installation, run your first test with:
# Run all tests
npx playwright test
# Open the HTML report in browser
npx playwright show-report
These are the 3 core building blocks of Playwright. Think of it like opening a browser manually:
Chrome / Firefox / Safari
Like an Incognito window
One tab / one URL
- Browser — The actual browser that Playwright opens. Can be Chrome, Firefox, or Safari.
- BrowserContext — An isolated session inside that browser. Like opening a new incognito window. Each context has its own cookies, login state, and storage. You can run multiple contexts in parallel.
- Page — One single tab inside a context. This is where you do all your actions — click, type, navigate, assert.
Auto-wait means Playwright automatically waits for an element to be ready before performing any action on it. You do not need to write manual wait commands.
Before clicking or typing, Playwright automatically checks that the element is:
- Present in the HTML
- Visible on screen (not hidden)
- Enabled (not disabled or greyed out)
- Stable (not moving or animating)
Playwright supports 3 browser engines that cover all major browsers:
- Chromium — runs Google Chrome and Microsoft Edge tests
- Firefox — runs Mozilla Firefox tests
- WebKit — runs Apple Safari tests (works on all OS, not just Mac)
You can run the same test file across all 3 browsers with a simple config change. This is called cross-browser testing.
projects: [
{ name: 'Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'Safari', use: { ...devices['Desktop Safari'] } },
]
Headless mode means the browser runs in the background — no browser window opens on screen. Tests run and work exactly the same, just without any visible UI.
| Mode | When to use |
|---|---|
| Headless (default) | Running on CI/CD servers like Jenkins or GitHub Actions — no screen available. Also faster for large test suites. |
| Headed (visible) | When writing new tests or debugging a failing test — you can see what is happening step by step. |
# Run with visible browser window
npx playwright test --headed
# Run in background (headless — this is default)
npx playwright test
playwright.config.js is the main settings file for your entire test project. You set common options here once so you do not have to repeat them in every test file.
Common things you configure here:
- Base URL of your application
- Which browsers to run tests on
- Timeout per test
- Whether to run tests in parallel
- Screenshot and video recording settings
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests', // where your test files are
fullyParallel: true, // run tests in parallel
retries: 1, // retry failed test once
timeout: 30000, // max 30 seconds per test
use: {
baseURL: 'https://your-website.com',
headless: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
Both are used to click an element. The difference is that locator.click() is the newer and more reliable approach recommended by Playwright.
| page.click() | locator.click() | |
|---|---|---|
| Style | Old way | New recommended way |
| Auto retry on failure | No | Yes — retries automatically |
| Strict mode | No | Yes — fails if multiple elements match |
// Old way — still works but not preferred
await page.click('#submit-btn');
// New recommended way using locator
await page.locator('#submit-btn').click();
// Best practice — use getByRole for accessibility
await page.getByRole('button', { name: 'Submit' }).click();
Playwright has built-in screenshot support. You can capture the full page, a specific element, or set it to capture automatically on test failure.
// Capture full page screenshot
await page.screenshot({ path: 'screenshot.png', fullPage: true });
// Capture only a specific element
await page.locator('.product-card').screenshot({ path: 'product.png' });
// In config — auto capture on every test failure
use: { screenshot: 'only-on-failure' }
Playwright has a built-in tool called Codegen. It opens a browser and records every action you do — clicks, typing, navigation — and automatically generates the test code in real time.
# Opens browser + code window side by side
npx playwright codegen https://www.flipkart.com
# Record and save the code directly to a file
npx playwright codegen --output=tests/test.js https://www.flipkart.com
Just browse the website normally — Playwright writes the code for you automatically.
The test() function is how you define a single test case. It takes a test name and a function that contains the actual steps. Every test you write is inside this function.
Structure of a basic Playwright test:
const { test, expect } = require('@playwright/test');
test('User can login successfully', async ({ page }) => {
// Step 1: Go to the website
await page.goto('https://example.com/login');
// Step 2: Fill in credentials
await page.fill('#email', 'test@gmail.com');
await page.fill('#password', 'Password@123');
// Step 3: Click login button
await page.click('#login-btn');
// Step 4: Verify login was successful
await expect(page).toHaveURL('/dashboard');
});
test.describe() is used to group related tests together — like a folder. It gives a name to a group of tests that test the same feature or page.
test.beforeEach() runs a block of code before every test inside that group. This avoids repeating the same setup steps in each test.
test.describe('Login Page Tests', () => {
// This runs before EVERY test in this group
test.beforeEach(async ({ page }) => {
await page.goto('https://example.com/login');
});
test('Valid login works', async ({ page }) => {
await page.fill('#email', 'valid@gmail.com');
await page.fill('#password', 'Valid@123');
await page.click('#login-btn');
await expect(page).toHaveURL('/dashboard');
});
test('Wrong password shows error message', async ({ page }) => {
await page.fill('#email', 'valid@gmail.com');
await page.fill('#password', 'wrongpass');
await page.click('#login-btn');
await expect(page.locator('.error-msg')).toBeVisible();
});
});
There are two ways to run a specific test — from terminal or from inside your test file.
# Run all tests
npx playwright test
# Run only one specific test file
npx playwright test tests/login.spec.js
# Run tests with a specific name or keyword
npx playwright test --grep "login"
# Run only on Chrome browser
npx playwright test --project=chromium
# Run in debug mode — opens browser step by step
npx playwright test --debug
// Use test.only() to run just this one test
test.only('Login test', async ({ page }) => {
// only this test will run in this file
});
// Remember to remove .only before committing to Git!
Ready to Learn Playwright
with GenAI?
A Locator is how you tell Playwright which element on the page to interact with. Before you can click a button or type in a field, Playwright first needs to find that element.
Playwright provides several built-in ways to find elements:
// By role — most recommended, works with accessibility
page.getByRole('button', { name: 'Login' })
page.getByRole('textbox', { name: 'Email' })
// By visible text on screen
page.getByText('Welcome to STAD Solution')
// By placeholder text inside an input
page.getByPlaceholder('Enter your email')
// By label text (for form fields)
page.getByLabel('Password')
// By test ID — most stable for automation
page.getByTestId('submit-button')
// By CSS selector
page.locator('#email-input')
page.locator('.btn-primary')Both CSS Selector and XPath are used to locate elements in the HTML. CSS Selector is faster and easier to read, while XPath is more powerful but complex.
| Point | CSS Selector | XPath |
|---|---|---|
| Speed | Faster | Slightly slower |
| Readability | Easy to read | Hard to read |
| Find by text | Not possible directly | Yes — //button[text()='Login'] |
| Navigate to parent | Not possible | Yes — /parent::div |
| Recommended | Yes — for most cases | Only when CSS cannot work |
// CSS Selector examples
page.locator('#login-btn') // by ID
page.locator('.submit-button') // by class
page.locator('input[type="email"]') // by attribute
// XPath examples
page.locator('//button[@id="login-btn"]')
page.locator('//button[text()="Login"]')getByRole() finds elements based on their ARIA role — the purpose they serve on the page. For example, a submit button has the role "button", an input field has the role "textbox", a navigation link has the role "link".
Why it is preferred:
- It finds elements the same way a screen reader or keyboard user would — by purpose, not by design
- If a developer changes the button's CSS class or ID, your test still works because the role stays the same
- Makes your tests more readable — anyone can understand what element you are referring to
// Find a button by its visible text
await page.getByRole('button', { name: 'Submit' }).click();
// Find a text input field by its label
await page.getByRole('textbox', { name: 'Email Address' }).fill('test@gmail.com');
// Find a heading on the page
await page.getByRole('heading', { name: 'Dashboard' });
// Find a checkbox
await page.getByRole('checkbox', { name: 'I agree to terms' }).check();getByTestId() finds elements using a special HTML attribute that developers add specifically for testing. It is the most stable way to locate elements because it does not change when the UI design is updated.
<button data-testid="submit-button">Submit</button>
<input data-testid="email-input" type="email" />await page.getByTestId('submit-button').click();
await page.getByTestId('email-input').fill('test@gmail.com');When multiple elements match your locator — like a list of products or table rows — you can use .all() to get all of them and loop through, or .count() to get the total number.
// Example: Get all product names from a listing page
const products = page.locator('.product-name');
// Count how many products are there
const count = await products.count();
console.log(`Total products: ${count}`);
// Get text of each product one by one
const allProducts = await products.all();
for (const product of allProducts) {
const name = await product.textContent();
console.log(name);
}
// Get text of specific item — 3rd item (index starts at 0)
const thirdItem = await products.nth(2).textContent();When your locator matches multiple elements, these methods let you pick a specific one by its position in the list.
// Say there are 5 products on the page
// All have the class 'product-card'
// Pick the FIRST product
page.locator('.product-card').first()
// Pick the LAST product
page.locator('.product-card').last()
// Pick the 3rd product (index starts from 0)
page.locator('.product-card').nth(2)
// Real example: Click 'Buy Now' button of the first product only
await page.locator('.product-card').first()
.locator('button.buy-now').click();You can chain locators — first find the parent container, then find the child element inside it. This is useful when the same element (like a button) appears inside multiple containers and you need to target a specific one.
// Problem: Each product card has its own 'Add to Cart' button
// You want to click 'Add to Cart' for the SECOND product only
// Step 1: Get the second product card
const secondCard = page.locator('.product-card').nth(1);
// Step 2: Find the button INSIDE that specific card
await secondCard.locator('button.add-to-cart').click();
// Another example: Find error message inside a specific form
const loginForm = page.locator('#login-form');
await expect(loginForm.locator('.error-text')).toBeVisible();.filter() narrows down a list of matched elements based on a condition — like finding a specific row in a table that contains certain text.
// Example: You have a table of orders
// You want to click 'Cancel' only for orders with status "Pending"
const pendingRow = page
.locator('tr.order-row')
.filter({ hasText: 'Pending' });
await pendingRow.locator('button.cancel-btn').click();
// Filter by containing a specific child element
const activeUsers = page
.locator('.user-card')
.filter({ has: page.locator('.status-active') });Some websites generate IDs dynamically — like btn_1234 that changes to a different number on every page load. If you use that ID in your locator, the test will break the next time the page loads.
Solutions to handle dynamic elements:
- Use getByRole with the button or link name — the visible text does not change
- Use getByText with the element's visible text content
- Use partial CSS —
[id^="btn_"]matches any ID starting with "btn_" - Ask the developer to add a stable data-testid attribute
// BAD — this ID changes every time, test will break
page.locator('#btn_1234')
// GOOD — use visible button text
page.getByRole('button', { name: 'Add to Cart' })
// GOOD — use data attribute
page.locator('[data-action="add-cart"]')
// GOOD — partial match on ID prefix
page.locator('[id^="btn_"]')| Method | Finds by | Best used for |
|---|---|---|
| getByText() | Visible text content of the element | Paragraphs, headings, buttons, links, any element with visible text |
| getByLabel() | Label associated with a form input | Input fields, dropdowns, checkboxes that have a label tag |
// getByText — find element by its visible text
await page.getByText('Welcome back, Rahul').isVisible();
await page.getByText('Logout').click();
// getByLabel — find input field associated with a label
// HTML: <label for="email">Email Address</label><input id="email">
await page.getByLabel('Email Address').fill('test@gmail.com');
await page.getByLabel('Password').fill('Pass@123');Playwright provides two methods — isVisible() returns true or false, while toBeVisible() is an assertion that fails the test if the element is not visible.
// Check visibility — returns true or false
const isVisible = await page.locator('.success-msg').isVisible();
console.log(isVisible); // true or false
// Assert element IS visible — test fails if not
await expect(page.locator('.success-msg')).toBeVisible();
// Assert element is NOT visible
await expect(page.locator('.error-msg')).not.toBeVisible();
// Check if element exists in DOM (even if hidden)
const exists = await page.locator('#modal').count() > 0;
console.log(exists); // true if present in HTMLThere are two methods — textContent() gets all text including hidden text, and innerText() gets only the visible text the user can see on screen.
// Get ALL text — includes text in hidden elements
const allText = await page.locator('.order-summary').textContent();
// Get only VISIBLE text — what user sees on screen
const visibleText = await page.locator('.order-summary').innerText();
// Real example: Get order ID after placing order
const orderId = await page.locator('.order-id-text').innerText();
console.log('Order placed with ID:', orderId);Use getAttribute() to read any HTML attribute of an element — like href, src, value, placeholder, disabled, etc.
// Get the href of a link
const href = await page.locator('a.learn-more').getAttribute('href');
console.log(href); // e.g. '/courses/automation-testing'
// Get the src of an image
const imgSrc = await page.locator('img.logo').getAttribute('src');
// Check if a button has the disabled attribute
const isDisabled = await page.locator('#submit-btn').getAttribute('disabled');
// Returns null if not disabled, returns "" or "disabled" if disabled
// Get placeholder text of input
const placeholder = await page.locator('#search-box').getAttribute('placeholder');Playwright's locator is in strict mode by default — if your locator matches more than one element and you try to click or type, it will throw an error saying "strict mode violation: locator resolved to X elements".
How to fix this:
- Make your locator more specific — add parent container, add more attributes
- Use .first(), .last(), or .nth() to pick a specific one
- Use .filter() to narrow down based on text or child element
// Problem: '.delete-btn' matches 5 buttons on the page
await page.locator('.delete-btn').click(); // ERROR — strict mode
// Solution 1: Be more specific
await page.locator('#order-123 .delete-btn').click();
// Solution 2: Use filter to find the right one
await page.locator('.order-row')
.filter({ hasText: 'Order #123' })
.locator('.delete-btn')
.click();
// Solution 3: Use first() if you intentionally want the first one
await page.locator('.delete-btn').first().click();Playwright recommends a priority order for choosing locators — from most stable to least stable. Always start from the top of this list:
| Priority | Locator | Why |
|---|---|---|
| 1st ✅ | getByRole() | Mirrors how users and screen readers find elements. Very stable. |
| 2nd ✅ | getByLabel() | Tied to form field labels. Stable when label text doesn't change. |
| 3rd ✅ | getByPlaceholder() | Good for inputs without visible labels. |
| 4th ✅ | getByTestId() | Most stable — specially added for automation. |
| 5th ⚠️ | getByText() | Can break if text is changed or translated. |
| 6th ⚠️ | CSS Selector | Can break when page design is updated. |
| 7th ❌ | XPath | Use only as last resort — fragile and hard to read. |
An assertion is a check in your test that verifies whether something is correct. Without assertions, your test just performs actions — it does not actually verify if the result is right or wrong. That means even if the website is broken, your test would still pass.
In Playwright, all assertions use the expect() function.
// Check current page URL after login
await expect(page).toHaveURL('/dashboard');
// Check page title
await expect(page).toHaveTitle('My App - Dashboard');
// Check element is visible on screen
await expect(page.locator('.success-msg')).toBeVisible();
// Check element has specific text
await expect(page.locator('h1')).toHaveText('Welcome, Rahul');
// Check element does NOT exist or is NOT visible
await expect(page.locator('.error-msg')).not.toBeVisible();Both check text content of an element, but they work differently:
| Method | What it checks | Example |
|---|---|---|
| toHaveText() | Exact full text match | Element must have EXACTLY "Order Placed" |
| toContainText() | Partial text match | Element just needs to CONTAIN the word "Placed" |
// Element text is: "Order #1234 Placed Successfully"
// toHaveText — needs EXACT match — this will FAIL
await expect(page.locator('.msg')).toHaveText('Placed'); // FAIL
// toHaveText — exact full text — this will PASS
await expect(page.locator('.msg')).toHaveText('Order #1234 Placed Successfully'); // PASS
// toContainText — partial match — this will PASS
await expect(page.locator('.msg')).toContainText('Placed'); // PASS
await expect(page.locator('.msg')).toContainText('Successfully'); // PASSBoth check if an element exists, but at different levels:
- toBeAttached() — checks that the element exists in the HTML code. It may be hidden with display:none or visibility:hidden, but it is in the DOM.
- toBeVisible() — checks that the element is both in the DOM AND actually visible to the user on screen.
// A toast message exists in HTML but is hidden (display:none)
await expect(page.locator('#toast')).toBeAttached(); // PASS — it is in HTML
await expect(page.locator('#toast')).toBeVisible(); // FAIL — user cannot see it
// After clicking Save button, toast becomes visible
await page.click('#save-btn');
await expect(page.locator('#toast')).toBeVisible(); // PASS nowFor input fields, you must use toHaveValue() — not toHaveText(). Input fields do not have text content — they have a value property. Using toHaveText() on an input will always fail.
// Fill a field and verify it was filled correctly
await page.fill('#mobile-number', '9876543210');
await expect(page.locator('#mobile-number')).toHaveValue('9876543210');
// Check a checkbox is checked
await expect(page.locator('#terms-checkbox')).toBeChecked();
// Check a checkbox is NOT checked
await expect(page.locator('#newsletter')).not.toBeChecked();
// Check submit button is disabled
await expect(page.locator('#submit-btn')).toBeDisabled();
// Check submit button is enabled
await expect(page.locator('#submit-btn')).toBeEnabled();Normally when an assertion fails, the test stops immediately. With soft assertion, the test continues running even after a failure — it collects all failures and reports them together at the end.
// Normal assertion — test STOPS if this fails
await expect(page.locator('h1')).toHaveText('Dashboard');
// Soft assertion — test CONTINUES even if this fails
await expect.soft(page.locator('h1')).toHaveText('Dashboard');
await expect.soft(page.locator('.user-name')).toHaveText('Rahul');
await expect.soft(page.locator('.plan-name')).toHaveText('Pro Plan');
await expect.soft(page.locator('.status')).toHaveText('Active');
// All soft failures are shown together at end of testUse toHaveCount() to assert the exact number of elements that match a locator.
// Verify exactly 5 products are shown on the page
await expect(page.locator('.product-card')).toHaveCount(5);
// Verify cart has 3 items
await expect(page.locator('.cart-item')).toHaveCount(3);
// Verify at least 1 result is shown after search
const count = await page.locator('.result-item').count();
expect(count).toBeGreaterThan(0);
// Verify no results when search finds nothing
await expect(page.locator('.result-item')).toHaveCount(0);Use toHaveURL() with either a full string, a partial string, or a regex pattern.
// Exact URL match
await expect(page).toHaveURL('https://example.com/dashboard');
// Partial match using regex — useful for dynamic URLs
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveURL(/order\/\d+/); // matches /order/1234
// Using string includes — check URL contains this text
const url = page.url();
expect(url).toContain('/order/');
expect(url).toContain('status=success');/order/5892/confirmation where 5892 is dynamic. Use regex /order\/\d+\/confirmation/ to match any order ID number.Playwright assertions automatically retry until the condition is met or the timeout is reached. This means you do not need to add manual waits before your assertions — Playwright handles it for you.
// After clicking submit, the success message loads in 2 seconds
// You do NOT need to add a manual wait here
await page.click('#submit-btn');
// Playwright keeps retrying this for up to 5 seconds automatically
await expect(page.locator('.success-msg')).toBeVisible();
// You can increase the timeout if needed
await expect(page.locator('.report-table')).toBeVisible({ timeout: 15000 });
// Now Playwright retries for up to 15 secondsUse waitFor() on a locator to explicitly wait for an element to reach a specific state.
// Wait for element to become visible
await page.locator('.data-table').waitFor({ state: 'visible' });
// Wait for loading spinner to disappear
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
// Wait with custom timeout
await page.locator('.pdf-preview').waitFor({ state: 'visible', timeout: 20000 });
// Real example: After clicking Search, wait for spinner
// to disappear before checking results
await page.click('#search-btn');
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
await expect(page.locator('.search-results')).toBeVisible();| waitForTimeout() | waitFor() | |
|---|---|---|
| What it does | Pauses test for a fixed time (like Thread.sleep) | Waits until a specific condition is met |
| Reliability | Unreliable — too short breaks tests, too long wastes time | Reliable — stops as soon as condition is true |
| Use in real projects | Never — anti-pattern | Yes — preferred approach |
// BAD — fixed sleep, unreliable and slow
await page.waitForTimeout(3000); // always waits 3 seconds even if ready in 0.5s
// GOOD — waits only until condition is true
await page.locator('.success-message').waitFor({ state: 'visible' });
// GOOD — wait for a page navigation
await page.waitForURL('**/dashboard');
// GOOD — wait for network request to finish
await page.waitForResponse('**/api/save');When you navigate to a URL, you can tell Playwright how much to wait using the waitUntil option.
| Option | What it waits for | When to use |
|---|---|---|
| domcontentloaded | HTML is parsed and DOM is ready | Fastest — for simple static pages |
| load (default) | Page and all resources loaded | Most common — good for most pages |
| networkidle | No network requests for 500ms | For pages that load data via APIs after load |
// Default — wait for 'load' event
await page.goto('https://example.com');
// Wait until all API calls are finished (good for dashboards)
await page.goto('https://example.com/dashboard', { waitUntil: 'networkidle' });
// Best practice: navigate + wait for a key element to appear
await page.goto('https://example.com/dashboard');
await page.locator('.dashboard-content').waitFor();Use toHaveCSS() to assert CSS property values of an element. This is useful for verifying UI styling — like checking an error message appears in red.
// Check error message text is red
await expect(page.locator('.error-msg'))
.toHaveCSS('color', 'rgb(220, 38, 38)');
// Check button has correct background color
await expect(page.locator('#submit-btn'))
.toHaveCSS('background-color', 'rgb(26, 86, 219)');
// Check element is hidden using CSS display
await expect(page.locator('.tooltip'))
.toHaveCSS('display', 'none');Use waitForResponse() to wait until a specific API call is completed. This is very useful when clicking a button triggers an API call and you want to wait for that call to finish before asserting the UI.
// Wait for API response when clicking Save button
const [response] = await Promise.all([
page.waitForResponse('**/api/save-profile'), // wait for this API
page.click('#save-btn') // trigger it by clicking
]);
// Verify API returned success status
expect(response.status()).toBe(200);
// Also verify the response body content
const body = await response.json();
expect(body.success).toBe(true);expect.poll() keeps calling a function repeatedly until the return value matches the expected condition — or until timeout. Use it when Playwright's built-in assertions cannot handle your condition.
// Example: Wait until a background job is completed
// The job status API is polled every few seconds
await expect.poll(async () => {
const response = await page.request.get('/api/job-status');
const body = await response.json();
return body.status;
}, {
timeout: 30000, // wait up to 30 seconds total
intervals: [1000, 2000, 5000] // check after 1s, 2s, then every 5s
}).toBe('completed');Many web applications show a loading spinner after clicking a button while data loads in the background. Your test must wait for the spinner to disappear before performing the next action — otherwise you may try to interact with elements that are not ready yet.
// Scenario: Search on an e-commerce site
// After clicking Search, a spinner shows while results load
// Step 1: Click the search button
await page.click('#search-btn');
// Step 2: Wait for spinner to disappear
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
// Step 3: Now results are ready — interact with them
await expect(page.locator('.search-results')).toBeVisible();
const count = await page.locator('.result-item').count();
console.log(`Found ${count} results`);Playwright handles two types of dropdowns differently — standard HTML <select> dropdowns and custom dropdowns built with divs or spans.
// Standard HTML select dropdown — use selectOption()
await page.selectOption('#country-select', 'India');
// Select by value attribute
await page.selectOption('#country-select', { value: 'IN' });
// Select by index (select the 3rd option)
await page.selectOption('#country-select', { index: 2 });
// Select multiple options (for multi-select dropdowns)
await page.selectOption('#skills', ['selenium', 'playwright', 'jmeter']);
// Custom dropdown (div/span based — not a select tag)
await page.click('.dropdown-toggle'); // open the dropdown
await page.click('text=India'); // click the option<select> tag or a custom component. If it is a select tag, use selectOption(). If it is built with divs and custom CSS, use click() to open and click() to select.Playwright has dedicated methods for checkboxes — check() and uncheck(). For radio buttons, use check() as well.
// Check a checkbox
await page.check('#terms-checkbox');
// Uncheck a checkbox
await page.uncheck('#newsletter-checkbox');
// Verify checkbox is checked
await expect(page.locator('#terms-checkbox')).toBeChecked();
// Verify checkbox is NOT checked
await expect(page.locator('#newsletter')).not.toBeChecked();
// Select a radio button
await page.check('input[value="online"]');
// Check using isChecked() method — returns true/false
const isChecked = await page.locator('#terms-checkbox').isChecked();
console.log('Is checked:', isChecked);Playwright has a simple setInputFiles() method for file uploads. It works directly with the file input element — no need to interact with the OS file dialog.
// Upload a single file
await page.setInputFiles('input[type="file"]', './files/resume.pdf');
// Upload multiple files at once
await page.setInputFiles('input[type="file"]', [
'./files/photo.jpg',
'./files/certificate.pdf'
]);
// Remove all uploaded files (clear the input)
await page.setInputFiles('input[type="file"]', []);
// When a button triggers the file dialog (not a direct input)
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.click('#upload-btn')
]);
await fileChooser.setFiles('./files/resume.pdf');test-data/ folder and reference them as './test-data/resume.pdf'.Browser dialogs (alert, confirm, prompt) are handled using the dialog event. You must register the handler BEFORE triggering the action that opens the dialog.
// Handle Alert — click OK
page.on('dialog', dialog => dialog.accept());
await page.click('#show-alert-btn');
// Handle Confirm — click Cancel
page.on('dialog', dialog => dialog.dismiss());
await page.click('#delete-btn');
// Handle Prompt — type a value and click OK
page.on('dialog', async dialog => {
console.log('Dialog message:', dialog.message());
await dialog.accept('Rahul Sharma'); // type this in the prompt
});
await page.click('#enter-name-btn');page.on('dialog', ...) BEFORE clicking the button that triggers the dialog. If you register it after, Playwright automatically dismisses the dialog before your handler runs.When clicking a link that opens in a new tab, use context.waitForEvent('page') to capture the new tab and work on it.
// Click a link that opens in a new tab
const [newTab] = await Promise.all([
context.waitForEvent('page'), // listen for new tab
page.click('a[target="_blank"]') // click link
]);
// Wait for new tab to fully load
await newTab.waitForLoadState();
// Work on the new tab
await expect(newTab).toHaveURL(/terms-and-conditions/);
await expect(newTab.locator('h1')).toBeVisible();
// Close new tab and go back to original tab
await newTab.close();
// 'page' variable still refers to the original tabAn iframe is a web page embedded inside another web page. Common examples are payment gateways (Razorpay, PayU), Google Maps, and YouTube embeds. Elements inside an iframe cannot be accessed directly — you must use frameLocator() first.
// Get the iframe using frameLocator()
const iframe = page.frameLocator('iframe[title="Payment Form"]');
// Now interact with elements INSIDE the iframe
await iframe.locator('#card-number').fill('4111 1111 1111 1111');
await iframe.locator('#expiry').fill('12/26');
await iframe.locator('#cvv').fill('123');
await iframe.locator('button.pay-now').click();
// You can also locate iframe by src URL
const mapFrame = page.frameLocator('iframe[src*="google.com/maps"]');Use hover() to move the mouse over an element — this triggers CSS :hover effects and shows tooltips or dropdown menus that appear on hover.
// Hover over user avatar to show profile dropdown menu
await page.hover('#user-avatar');
// After hover, dropdown appears — now click an option
await page.click('text=My Profile');
// Hover to reveal tooltip and verify its text
await page.locator('.info-icon').hover();
await expect(page.locator('.tooltip')).toBeVisible();
await expect(page.locator('.tooltip')).toContainText('Click to learn more');// Press Enter after typing in search box
await page.fill('#search', 'Playwright');
await page.press('#search', 'Enter');
// Press Tab to move focus to next field
await page.press('#email', 'Tab');
// Press Escape to close a modal
await page.keyboard.press('Escape');
// Ctrl+A to select all text in a field
await page.press('#text-editor', 'Control+A');
// Shift+Click to select multiple items in a list
await page.click('.item:nth-child(1)');
await page.click('.item:nth-child(5)', { modifiers: ['Shift'] });| Method | How it works | Speed | Use when |
|---|---|---|---|
| fill() | Clears the field and sets value directly | Fast | Most form inputs — standard text, email, password |
| type() | Simulates actual key press for each character | Slow | Search inputs with live autocomplete suggestions |
| pressSequentially() | Like type() but with configurable delay between keys | Slowest | When the app needs a typing delay to trigger events |
// fill() — use for most inputs
await page.fill('#email', 'test@gmail.com');
// type() — use for autocomplete search inputs
// Each keypress triggers a suggestion list update
await page.locator('#city-search').type('Mumbai');
await page.locator('.suggestion-item').first().click();Playwright automatically scrolls to elements before clicking them. But sometimes you need to scroll manually — like to load lazy-loaded content or to scroll inside a specific container.
// Scroll a specific element into view
await page.locator('#footer-section').scrollIntoViewIfNeeded();
// Scroll to bottom of page (for lazy loading)
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
// Scroll to top of page
await page.evaluate(() => window.scrollTo(0, 0));
// Scroll inside a specific container (like a modal)
await page.locator('.modal-body').evaluate(
el => el.scrollTop = el.scrollHeight
);// Method 1: dragTo() — simplest approach
await page.locator('#draggable-item').dragTo(
page.locator('#drop-zone')
);
// Verify item landed in drop zone
await expect(page.locator('#drop-zone .item')).toBeVisible();
// Method 2: Manual mouse events (when dragTo doesn't work)
await page.locator('#drag-item').hover();
await page.mouse.down();
await page.mouse.move(500, 300); // move to x,y coordinates
await page.mouse.up();test('Invoice PDF downloads successfully', async ({ page }) => {
// Listen for download event and click button simultaneously
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('#download-invoice-btn')
]);
// Verify the filename is correct
expect(download.suggestedFilename()).toContain('invoice');
// Save the file and verify it is not empty
const path = await download.path();
const fs = require('fs');
const fileSize = fs.statSync(path).size;
expect(fileSize).toBeGreaterThan(0);
console.log('Downloaded file size:', fileSize, 'bytes');
});Cookie banners, newsletter popups, and chat widgets can appear randomly during test execution. These need to be handled so they do not block your test actions.
// Method 1: Check if popup is visible, close it if present
const popup = page.locator('.newsletter-popup');
if (await popup.isVisible()) {
await page.locator('.popup-close-btn').click();
}
// Method 2: addLocatorHandler — auto-handles popup whenever it appears
// Add this in beforeEach so it runs before every test
await page.addLocatorHandler(
page.locator('.cookie-banner'),
async () => {
await page.locator('#accept-cookies').click();
}
);Date pickers come in two types — standard HTML date inputs and custom calendar components. Each needs a different approach.
// Type 1: Standard HTML date input — use fill() with YYYY-MM-DD format
await page.fill('input[type="date"]', '2024-12-25');
// Type 2: If fill() doesn't work — set value via JavaScript
await page.evaluate(() => {
document.querySelector('input[type="date"]').value = '2024-12-25';
});
// Type 3: Custom calendar picker (click through month/day)
await page.click('.datepicker-input'); // open calendar
await page.click('text=December 2024'); // navigate to month
await page.click('[aria-label="December 25, 2024"]'); // pick the dayUse page.evaluate() to run JavaScript code directly inside the browser — as if you typed it in the browser Console. This is useful when Playwright's built-in methods cannot do what you need.
// Get the page title using JS
const title = await page.evaluate(() => document.title);
// Set a value in localStorage (for login token injection)
await page.evaluate(() => {
localStorage.setItem('auth_token', 'test-token-123');
});
// Remove a blocking overlay or cookie banner using JS
await page.evaluate(() => {
document.querySelector('.cookie-overlay')?.remove();
});
// Pass values from test into the browser
const discount = await page.evaluate((price) => {
return price * 0.1;
}, 500); // returns 50I was doing manual testing for 2 years with no growth. After completing the Playwright course at STAD Solution, I learned end-to-end automation, API testing, and CI/CD integration with real projects. Got placed within 45 days of completing the course.
Page Object Model is a design pattern where you create a separate class (file) for each page of the application. This class stores all the locators and actions for that page. Your test files then use these classes instead of writing locators and actions directly inside tests.
Why teams use POM:
- If a locator changes, you update it in ONE place — not in 50 test files
- Tests become shorter and easier to read
- Same page class is reused across multiple test files
- New team members can understand tests faster
LoginPage.js
DashboardPage.js
login.spec.js
A Page Object class has two parts — the constructor where you define locators, and methods where you define actions that can be performed on that page.
class LoginPage {
constructor(page) {
this.page = page;
// Define all locators here — one place to update if UI changes
this.emailInput = page.locator('#email');
this.passwordInput = page.locator('#password');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.locator('.error-msg');
}
// Action methods — reusable across multiple tests
async goto() {
await this.page.goto('/login');
}
async login(email, password) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage() {
return await this.errorMessage.textContent();
}
}
module.exports = { LoginPage };const { test, expect } = require('@playwright/test');
const { LoginPage } = require('../pages/LoginPage');
test('Valid login works', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@test.com', 'Pass@123');
await expect(page).toHaveURL('/dashboard');
});
test('Wrong password shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@test.com', 'wrongpass');
await expect(page.locator('.error-msg')).toBeVisible();
});A well-structured Playwright project separates test files, page objects, test data, and utilities into different folders.
my-playwright-project/
│
├── pages/ ← Page Object classes
│ ├── LoginPage.js
│ ├── DashboardPage.js
│ └── CheckoutPage.js
│
├── tests/ ← Test files
│ ├── login.spec.js
│ ├── checkout.spec.js
│ └── dashboard.spec.js
│
├── test-data/ ← Test input data
│ ├── users.json
│ └── products.json
│
├── utils/ ← Helper functions
│ └── helpers.js
│
├── auth/ ← Saved login sessions
│ └── admin-session.json
│
└── playwright.config.js ← Main config fileData-driven testing means running the same test multiple times with different input values. Instead of writing 5 separate tests for 5 user types, you write 1 test and supply 5 data sets.
const loginData = [
{ email: 'admin@test.com', password: 'Admin@123', role: 'Admin' },
{ email: 'user@test.com', password: 'User@123', role: 'User' },
{ email: 'manager@test.com', password: 'Mgr@123', role: 'Manager' },
];
// Loop creates 3 separate tests automatically
for (const data of loginData) {
test(`Login as ${data.role}`, async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(data.email, data.password);
await expect(page.locator('.role-label')).toHaveText(data.role);
});
}Fixtures are reusable setup blocks that are injected directly into test functions. They are more powerful than beforeEach because they are lazy — they only run if a test actually uses them.
| beforeEach | Fixtures | |
|---|---|---|
| Runs when | Before every test in the group | Only when a test requests it |
| Reusability | Only in the same file | Shared across all test files |
| Best for | Simple one-file setup | Large projects with many test files |
const { test: base } = require('@playwright/test');
const { LoginPage } = require('../pages/LoginPage');
const test = base.extend({
// loginPage fixture — auto creates LoginPage for every test
loginPage: async ({ page }, use) => {
const login = new LoginPage(page);
await login.goto();
await use(login); // provide it to the test
}
});
module.exports = { test };const { test } = require('../fixtures');
const { expect } = require('@playwright/test');
// loginPage is injected automatically — no new LoginPage() needed
test('Valid login works', async ({ loginPage, page }) => {
await loginPage.login('user@test.com', 'Pass@123');
await expect(page).toHaveURL('/dashboard');
});storageState saves your logged-in browser session — cookies, localStorage, sessionStorage — to a JSON file. You can load this file in your tests so they start already logged in, skipping the login step completely.
Why this matters: If you have 50 tests that all need login, without storageState each test logs in and logs out — that is 50 extra login flows. With storageState, login happens only once and all 50 tests reuse that session.
test('Save admin login session', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'admin@test.com');
await page.fill('#password', 'Admin@123');
await page.click('#login-btn');
await expect(page).toHaveURL('/dashboard');
// Save the session to a file
await page.context().storageState({ path: './auth/admin-session.json' });
});use: {
// All tests start already logged in as admin
storageState: './auth/admin-session.json'
}Playwright runs tests in parallel by default — multiple tests run at the same time using separate workers. Each worker gets its own browser and BrowserContext so tests are completely isolated.
// Run all tests in all files in parallel
fullyParallel: true,
// Control how many tests run at the same time
workers: 4, // 4 tests run simultaneously
// Run tests in a specific FILE one after another
// Use when tests in a file depend on each other
test.describe.configure({ mode: 'serial' });Tags help you organize and selectively run tests. Add tags using @tag in the test name, then filter by tag from the terminal.
// Add @tags to test names
test('Login test @smoke', async ({ page }) => { });
test('Checkout test @smoke @regression', async ({ page }) => { });
test('Profile update @regression', async ({ page }) => { });
test('Payment @regression @payment', async ({ page }) => { });# Run only smoke tests (quick sanity check)
npx playwright test --grep @smoke
# Run full regression suite
npx playwright test --grep @regression
# Run only payment-related tests
npx playwright test --grep @payment
# Skip smoke tests, run everything else
npx playwright test --grep-invert @smokePlaywright has two powerful built-in tools for understanding test results:
HTML Report — A visual report showing all test results with pass/fail status, screenshots, error messages, and test duration. You can share this with your team or manager after a test run.
Trace Viewer — A step-by-step recording of everything that happened during a test — every click, every network call, every DOM snapshot. When a test fails, you open the trace to see exactly what went wrong.
reporter: ['html'], // generates HTML report
use: {
trace: 'retain-on-failure', // save trace only when test fails
screenshot: 'only-on-failure', // save screenshot on failure
video: 'retain-on-failure', // save video on failure
}# Open HTML report in browser
npx playwright show-report
# Open trace file to debug a failure
npx playwright show-trace trace.zipCI/CD integration means your Playwright tests run automatically every time someone pushes code to GitHub. If tests fail, the code merge is blocked — preventing broken code from reaching production.
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload HTML report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/Yes. Playwright has a built-in request object that lets you send HTTP requests — GET, POST, PUT, DELETE — directly inside your test code without opening a browser.
| Playwright API Testing | Postman | |
|---|---|---|
| Where it runs | Inside your test code — automated | Manual tool — run by a person |
| CI/CD | Yes — runs automatically in pipeline | Needs extra setup (Newman CLI) |
| Combine with UI | Yes — API + UI in same test | No — separate tool |
| Best for | Automated regression testing | Quick manual exploration |
Use the request fixture provided by Playwright. It works like a built-in HTTP client — similar to axios or fetch but inside your test.
const { test, expect } = require('@playwright/test');
test('GET — Verify user data is returned correctly', async ({ request }) => {
// Send GET request
const response = await request.get('https://reqres.in/api/users/1');
// Verify status code is 200
expect(response.status()).toBe(200);
// Parse the JSON response body
const body = await response.json();
// Verify the response data
expect(body.data.id).toBe(1);
expect(body.data.email).toBeTruthy();
console.log('User email:', body.data.email);
});test('POST — Create a new user', async ({ request }) => {
const response = await request.post('https://reqres.in/api/users', {
data: {
name: 'Rahul Sharma',
job: 'QA Engineer'
}
});
// 201 = Created successfully
expect(response.status()).toBe(201);
const body = await response.json();
// Verify response has the data we sent
expect(body.name).toBe('Rahul Sharma');
expect(body.job).toBe('QA Engineer');
// Server should have generated an ID
expect(body.id).toBeTruthy();
console.log('New user created with ID:', body.id);
});// PUT — update existing record
test('PUT — Update user details', async ({ request }) => {
const response = await request.put('https://reqres.in/api/users/2', {
data: {
name: 'Rahul Sharma',
job: 'Senior QA'
}
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.job).toBe('Senior QA');
});
// DELETE — remove a record
test('DELETE — Remove a user', async ({ request }) => {
const response = await request.delete('https://reqres.in/api/users/2');
// 204 = No Content — deleted successfully
expect(response.status()).toBe(204);
});Most real APIs require authentication — you send a token in the request headers. Playwright makes this easy with the headers option.
// Send request with Authorization header (Bearer token)
const response = await request.get('https://api.example.com/orders', {
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
expect(response.status()).toBe(200);
// You can also set headers globally for all requests
// in playwright.config.js
use: {
extraHTTPHeaders: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
}Instead of clicking through the UI to create test data, call the API directly — it is 5-10x faster. This technique is called API + UI hybrid testing and is highly valued in professional QA teams.
test('Verify new order appears in order list', async ({ page, request }) => {
// Step 1: Create order via API — fast, no UI clicks needed
const apiResponse = await request.post('/api/orders', {
headers: { 'Authorization': `Bearer ${process.env.API_TOKEN}` },
data: { product: 'Selenium Course', amount: 4999 }
});
const order = await apiResponse.json();
const orderId = order.id;
// Step 2: Open UI and verify the order appears on screen
await page.goto('/orders');
await expect(page.locator(`#order-row-${orderId}`)).toBeVisible();
await expect(page.locator(`#order-row-${orderId} .status`)).toHaveText('Pending');
});Network interception lets you intercept any network request your page makes and return fake data instead of hitting the real server. This is useful for testing specific UI states without depending on the backend.
// Mock an API — return fake response instead of real server
await page.route('**/api/courses', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Selenium Course', price: 4999 },
{ id: 2, name: 'Playwright Course', price: 5999 }
])
});
});
// Now when page loads, it shows your fake data
await page.goto('/courses');
await expect(page.locator('.course-card')).toHaveCount(2);
// Block all image requests to speed up tests
await page.route('**/*.{png,jpg,jpeg,gif,webp}', route => route.abort());Use route interception to simulate server errors and then verify the UI shows a proper error message to the user.
test('Shows error message when API fails', async ({ page }) => {
// Make the products API return a 500 server error
await page.route('**/api/products', route => {
route.fulfill({ status: 500, body: 'Internal Server Error' });
});
await page.goto('/products');
// UI should show a user-friendly error message
await expect(page.locator('.error-banner')).toBeVisible();
await expect(page.locator('.error-banner')).toContainText('Something went wrong');
// A retry button should appear
await expect(page.locator('button.retry')).toBeVisible();
});
test('Shows 404 message when product not found', async ({ page }) => {
await page.route('**/api/products/999', route => {
route.fulfill({ status: 404, body: 'Not Found' });
});
await page.goto('/products/999');
await expect(page.locator('.not-found-msg')).toBeVisible();
});| request fixture | page.request | |
|---|---|---|
| Context | Standalone — no browser context | Shares browser context — has cookies and session |
| Cookies | No browser cookies | Uses same cookies as the page |
| Best for | Pure API tests, setting up test data before UI tests | Calling APIs that need the user's login session |
// request fixture — standalone, no browser cookies
test('API test', async ({ request }) => {
const res = await request.get('/api/public-data');
});
// page.request — shares session with logged-in page
test('Logged-in API call', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'user@test.com');
await page.click('#login-btn');
// This API call uses the same session as the logged-in browser
const res = await page.request.get('/api/my-orders');
expect(res.status()).toBe(200);
});After parsing the response body, you can check individual fields, data types, array lengths, and nested objects. For complex schemas, use a library like zod or ajv.
test('Verify full API response structure', async ({ request }) => {
const response = await request.get('https://reqres.in/api/users?page=1');
expect(response.status()).toBe(200);
const body = await response.json();
// Check top-level fields exist
expect(body).toHaveProperty('data');
expect(body).toHaveProperty('total');
expect(body).toHaveProperty('per_page');
// Check data is an array with items
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBeGreaterThan(0);
// Check structure of first user in the array
const firstUser = body.data[0];
expect(firstUser).toHaveProperty('id');
expect(firstUser).toHaveProperty('email');
expect(firstUser).toHaveProperty('first_name');
// Check data types
expect(typeof firstUser.id).toBe('number');
expect(typeof firstUser.email).toBe('string');
// Check email format using regex
expect(firstUser.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
});Trace Viewer is like a complete recording of your test run. It records every action, every network request, every screenshot, and every DOM snapshot step by step. When a test fails — especially on a CI server where you cannot see the browser — Trace Viewer shows you exactly what happened.
What Trace Viewer shows you:
- A screenshot of the page at every test step
- The exact line of code that failed
- All network requests made during the test
- Console logs and errors from the browser
- DOM snapshot — inspect any element at any point in time
use: {
// Record trace only when test fails
trace: 'retain-on-failure'
}# After a test fails, open the trace file
npx playwright show-trace test-results/trace.zip
# Or open online without installing anything
# Go to: https://trace.playwright.dev and upload the zip filePlaywright comes with a built-in HTML reporter that generates a beautiful, interactive report showing all test results — pass, fail, skip — with screenshots and traces attached to failed tests.
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['list'] // also shows results in terminal
],# Run tests and generate report
npx playwright test
# Open the HTML report in browser
npx playwright show-reportGitHub Actions automatically runs your Playwright tests every time code is pushed to the repository. If tests fail, the merge is blocked — this prevents broken code from going to production.
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/Tags let you group and selectively run tests. In a large test suite, you might have smoke tests (quick sanity checks), regression tests (full coverage), and payment tests. Tags let you run only what you need.
// Add @tag in the test name
test('Login works @smoke', async ({ page }) => { ... });
test('Checkout flow @regression @payment', async ({ page }) => { ... });
test('Profile update @regression', async ({ page }) => { ... });# Run only smoke tests (quick pre-deployment check)
npx playwright test --grep @smoke
# Run all regression tests
npx playwright test --grep @regression
# Run everything EXCEPT smoke tests
npx playwright test --grep-invert @smoke
# Run tests matching multiple tags
npx playwright test --grep "@smoke|@payment"Visual testing compares a current screenshot of the page with a saved baseline screenshot. If anything on the UI has changed — even by 1 pixel — the test fails. This catches accidental visual regressions like broken layouts, wrong colors, or shifted elements.
// First run: saves screenshot as baseline
// All future runs: compares with baseline
await expect(page).toHaveScreenshot('homepage.png');
// Compare only a specific element
await expect(page.locator('.hero-section')).toHaveScreenshot('hero.png');
// Allow small differences (0.1 = allow up to 10% pixel difference)
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.05 // 5% difference allowed
});
# Update baseline when you intentionally change the UI
# npx playwright test --update-snapshotsPlaywright has 100+ built-in device profiles — each with the correct viewport size, user agent, and touch settings. You can run your tests on any device without needing a real phone.
const { devices } = require('@playwright/test');
projects: [
// Desktop
{ name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
// Mobile devices
{ name: 'iPhone 14', use: { ...devices['iPhone 14'] } },
{ name: 'Galaxy S23', use: { ...devices['Galaxy S23'] } },
{ name: 'iPad Pro', use: { ...devices['iPad Pro'] } },
]npx playwright devices| Method | What it does | When to use |
|---|---|---|
| test.skip() | Test is skipped — not run at all | Feature not yet built, or test environment issue |
| test.only() | Only this test runs — all others are skipped | Temporarily run one test during development |
| test.fixme() | Test runs but is expected to fail — shown separately | Known bug tracked in JIRA — test documents the bug |
// Skip this test completely
test.skip('Payment test — gateway is down in staging', async ({ page }) => {
// will not run
});
// Run ONLY this test (remove before committing!)
test.only('Login test', async ({ page }) => {
// only this runs
});
// Known bug — test runs but failure is expected
test.fixme('Cart count is wrong — BUG-1234', async ({ page }) => {
// documents the known bug
});
// Conditional skip — skip on Firefox only
test('Chrome-only feature test', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'This feature is Chrome-only');
// rest of test
});Playwright has several built-in debugging tools — use them in order from quickest to most detailed.
# 1. Run with visible browser — see what is happening
npx playwright test --headed
# 2. Debug mode — opens Inspector, run step by step
npx playwright test --debug
# 3. UI mode — visual test runner with time-travel
npx playwright test --ui
# 4. Run only the failing test file
npx playwright test tests/login.spec.js --headedtest('Debug this test', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'test@test.com');
// Test pauses here — Inspector opens — you can inspect live
await page.pause();
await page.click('#login-btn');
});A flaky test is a test that sometimes passes and sometimes fails — without any code changes. It is one of the most discussed problems in QA teams because it destroys trust in the test suite.
Common causes and how to fix them:
- Hard-coded waits — Replace
waitForTimeout(2000)withwaitFor({ state: 'visible' }) - Race condition — API call not finished before assertion — use
waitForResponse()to wait for the API - Unstable locator — Element re-renders and locator loses reference — switch to
getByTestIdorgetByRole - Test data collision — Multiple parallel tests using the same data — use unique data per test
- Animation not finished — Element is still moving when you try to click — wait for animation to complete
// Retry failed tests automatically (max 2 times in CI)
retries: process.env.CI ? 2 : 0,UI Mode is a visual test runner that opens in your browser. It shows all your test files, lets you run individual tests, watch them execute in real time, and see screenshots and traces for every step — all without writing any terminal commands.
npx playwright test --uiKey features of UI Mode:
- All tests listed in a sidebar — click any test to run it
- Watch mode — test automatically reruns when you save your code
- Time-travel debugging — scrub through screenshots at each step
- Filter tests by file, status (pass/fail), or name
- See network requests, console logs, and DOM snapshots side by side
Step-by-step what to check:
- Check the CI failure screenshot and trace — Playwright saves these on failure. Look at what the page looked like when it failed.
- OS difference — Local is Windows or Mac, CI is Linux. Check if file paths, fonts, or case sensitivity differ.
- Timing issues — CI servers are slower than local machines. Increase timeouts in config for CI.
- Missing test data — CI has a fresh database. Data you have locally may not exist in CI.
- Browser version mismatch — Run
npx playwright installin CI to ensure the same browser version is used. - Environment variables missing — Check if API keys, base URLs are set in CI secrets correctly.
- Hard-coded waits — Replace any
waitForTimeout()with proper element waits.
test('Complete checkout flow', async ({ page, request }) => {
// Step 1: Login via API (faster than UI login)
const loginRes = await request.post('/api/login', {
data: { email: 'user@test.com', password: 'Test@123' }
});
const { token } = await loginRes.json();
// Step 2: Set auth token in browser
await page.goto('/products');
await page.evaluate(t => localStorage.setItem('token', t), token);
await page.reload();
// Step 3: Search and add product
await page.fill('#search-box', 'Selenium Course');
await page.press('#search-box', 'Enter');
await page.locator('.product-card').first().locator('.add-to-cart').click();
await expect(page.locator('.cart-count')).toHaveText('1');
// Step 4: Checkout
await page.click('#cart-icon');
await page.click('#proceed-to-checkout');
await page.fill('#delivery-address', '123 Test Street, Ahmedabad');
// Step 5: Place order and verify
const [response] = await Promise.all([
page.waitForResponse('**/api/orders'),
page.click('#place-order-btn')
]);
expect(response.status()).toBe(201);
await expect(page.locator('.order-success-msg')).toBeVisible();
});Step 1 — Reproduce it: Run the test 10 times in a loop to confirm it is actually flaky and see how often it fails.
# Run same test 10 times to confirm flakiness
npx playwright test tests/checkout.spec.js --repeat-each=10Step 2 — Identify the root cause:
- Open Trace Viewer of a failed run — see exactly which step failed
- Check if there is a
waitForTimeout()— replace with element-based wait - Check if element locator is unstable — switch to getByTestId or getByRole
- Check if two parallel tests are using the same test data — make data unique per test
- Check if an animation is still running when you try to click
// WRONG — fixed sleep causes flakiness
await page.waitForTimeout(2000);
await page.click('#submit');
// RIGHT — wait for actual condition
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
await page.click('#submit');
// WRONG — unstable locator breaks on re-render
await page.locator('.MuiButton-root-123').click();
// RIGHT — stable locator
await page.getByRole('button', { name: 'Submit' }).click();3 approaches — explain all three in the interview:
Approach 1 — Test phone number with fixed OTP (Best)
Ask the developer to create a test mobile number like 9999999999 whose OTP is always a fixed value like 123456 in staging/test environment.
await page.fill('#mobile', '9999999999');
await page.click('#send-otp');
await page.fill('#otp-input', '123456'); // fixed test OTP
await page.click('#verify-otp');Approach 2 — Mock the OTP verification API
// Intercept OTP verify API and return success always
await page.route('**/api/verify-otp', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ success: true, token: 'test-auth-token' })
});
});
await page.fill('#otp-input', '000000'); // any value works now
await page.click('#verify-otp');You should never try to solve or bypass the actual CAPTCHA in automation. That defeats the purpose of CAPTCHA. The correct approach is to make the test environment testable.
4 correct approaches — explain these in order:
- Option 1 (Best): Ask the developer to disable CAPTCHA in the staging or test environment. Most apps have a flag for this.
- Option 2: Developer adds a special header or test token that bypasses CAPTCHA when sent with the request.
- Option 3: Mock the CAPTCHA verification API to always return success in tests.
- Option 4 (Last resort): Use a CAPTCHA solving service — expensive, slow, and not reliable.
test('Pagination works correctly', async ({ page }) => {
await page.goto('/products');
// Verify page 1 is active by default
await expect(page.locator('.page-btn.active')).toHaveText('1');
// Get first product name on page 1
const firstOnPage1 = await page.locator('.product-name').first().textContent();
// Verify page 1 shows correct number of products (e.g. 12 per page)
await expect(page.locator('.product-card')).toHaveCount(12);
// Go to page 2
await page.click('[aria-label="Next page"]');
await expect(page.locator('.page-btn.active')).toHaveText('2');
// First product on page 2 should be different from page 1
const firstOnPage2 = await page.locator('.product-name').first().textContent();
expect(firstOnPage2).not.toBe(firstOnPage1);
// Go back to page 1
await page.click('[aria-label="Previous page"]');
await expect(page.locator('.page-btn.active')).toHaveText('1');
});test('Complete 3-step registration', async ({ page }) => {
await page.goto('/register');
// Step 1: Personal Details
await expect(page.locator('.step-indicator')).toHaveText('Step 1 of 3');
await page.fill('#full-name', 'Rahul Sharma');
await page.fill('#mobile', '9876543210');
await page.fill('#email', 'rahul@test.com');
await page.click('#next-btn');
// Step 2: Course Selection
await expect(page.locator('.step-indicator')).toHaveText('Step 2 of 3');
await page.click('label[for="course-automation"]');
await page.selectOption('#batch-time', 'Morning');
await page.click('#next-btn');
// Step 3: Payment
await expect(page.locator('.step-indicator')).toHaveText('Step 3 of 3');
await page.click('#pay-now-btn');
// Verify success
await expect(page.locator('.registration-success')).toBeVisible();
await expect(page.locator('.confirm-email')).toContainText('rahul@test.com');
});test('Empty form shows required field errors', async ({ page }) => {
await page.goto('/register');
// Click Submit without filling anything
await page.click('#submit-btn');
// All required field errors should appear
await expect(page.locator('#name-error')).toBeVisible();
await expect(page.locator('#email-error')).toBeVisible();
await expect(page.locator('#mobile-error')).toBeVisible();
// Verify error message text is correct
await expect(page.locator('#email-error')).toHaveText('Email is required');
// Form should NOT have submitted — still on same page
await expect(page).toHaveURL(/\/register/);
});
test('Invalid email format shows error', async ({ page }) => {
await page.goto('/register');
await page.fill('#email', 'notanemail');
await page.click('#submit-btn');
await expect(page.locator('#email-error')).toContainText('valid email');
});Step-by-step approach:
- Understand the application — Spend 1-2 weeks doing manual testing. Understand the key user flows, critical features, and existing bugs.
- Set up the project — Initialize Playwright with TypeScript, configure playwright.config.js with base URL, timeouts, retries, reporters.
- Define folder structure — Create /pages for POM, /tests for spec files, /fixtures for reusable setup, /test-data for data files.
- Start with smoke tests — Write 10-15 tests covering the most critical flows: login, main navigation, core business action. Get these stable first.
- Set up CI/CD — Add GitHub Actions workflow. Smoke tests run on every pull request. Full regression runs nightly.
- Expand coverage gradually — Add more tests every sprint. Prioritize tests for features with most bugs or highest business impact.
- Report to team — Share weekly test coverage metrics and failure reports so the team trusts and uses the automation.
Step-by-step approach:
- Do not panic — Open Trace Viewer immediately. Find out if it is a real application bug or a test issue (wrong locator, timing problem).
- Reproduce manually — Open the browser and manually test the same scenario in 2 minutes. If it fails manually, it is a real bug. If it passes manually, it is likely a test issue.
- Communicate immediately — Tell the release manager right away — "Critical test is failing, investigating now." Never stay silent hoping it will resolve itself.
- If it is a real bug — Raise a blocker ticket with screenshots. The release manager and developers decide whether to fix it, defer it, or release with a known issue.
- If it is a test issue — Quick fix the locator or wait condition, re-run the test, confirm it passes. Document what was wrong.
- Document everything — Write a brief incident note: what failed, what was the root cause, what was done. This prevents the same issue next time.
Practice these answers, understand the concepts, and you are ready for your interview.