TypeScript · @playwright/test
Deep dives with Playwright
9 topics that separate a script from a suite — each with a complete example that runs against this site as is.
Deep dive 1Beginner
Locators that survive a redesign
Most flaky suites are really fragile-locator suites. Find elements the way a user does — by role, label and text — and narrow down with filters instead of long CSS or XPath chains.
- ✓getByRole and getByLabel read like the page and double as an accessibility check
- ✓filter({ hasText }) and filter({ has }) pick one card out of many, then you act inside it
- ✓getByTestId is the stable fallback when there is no role or label to use
import { test, expect } from '@playwright/test';
test('find a product the way a shopper does', async ({ page }) => {
await page.goto('/shop');
await page.getByLabel('Username').fill('demo_user');
await page.getByLabel('Password').fill('Demo@123');
await page.getByRole('button', { name: 'Sign in' }).click();
// One card out of twelve, found by its text — then act inside it
const lamp = page.getByTestId('product-card').filter({ hasText: 'Lumen Desk Lamp' });
await expect(lamp.getByTestId('product-price')).toHaveText('$34.99');
await lamp.getByRole('button', { name: 'Add to cart' }).click();
await expect(lamp.getByRole('button', { name: 'Remove' })).toBeVisible();
// Filter by a child locator: every card that carries a "Sale" badge
const onSale = page
.getByTestId('product-card')
.filter({ has: page.getByTestId('product-badge').getByText('Sale') });
await expect(onSale).toHaveCount(2);
});Deep dive 2Beginner
Auto-waiting and web-first assertions
Playwright waits for you — if you let it. Assertions on locators retry until they pass, so a fixed sleep is never the answer.
- ✓expect(locator) and expect(page) retry; expect(value) does not
- ✓slow_user takes about three seconds to sign in — wait for the URL, not for time
- ✓expect.poll retries a value you compute yourself, such as “are these prices sorted?”
import { test, expect } from '@playwright/test';
test('slow_user: wait for the result, never for time', async ({ page }) => {
await page.goto('/shop');
await page.getByTestId('username').fill('slow_user');
await page.getByTestId('password').fill('Demo@123');
await page.getByTestId('login-button').click();
await expect(page.getByTestId('login-spinner')).toBeVisible();
await expect(page).toHaveURL(/\/shop\/products$/); // retries until the slow login finishes
await expect(page.getByTestId('product-card')).toHaveCount(12);
});
test('poll a value you compute yourself', async ({ page }) => {
await page.goto('/shop');
await page.getByTestId('username').fill('demo_user');
await page.getByTestId('password').fill('Demo@123');
await page.getByTestId('login-button').click();
await page.getByTestId('sort-select').selectOption('price-asc');
await expect
.poll(async () => {
const texts = await page.getByTestId('product-price').allTextContents();
const prices = texts.map((t) => Number(t.replace('$', '')));
return prices.every((p, i) => i === 0 || prices[i - 1] <= p);
})
.toBe(true);
});Deep dive 3Intermediate
Fixtures: set-up you can name
A fixture is set-up and tear-down with a name. Tests ask for what they need — “a signed-in shop” — and stay short, and nothing leaks between them.
- ✓test.extend adds your own fixture next to page, request and context
- ✓Everything before use() is set-up; everything after it is tear-down
- ✓A test that doesn't ask for the fixture doesn't pay for it
import { test as base, expect, type Page } from '@playwright/test';
// A page that is already signed in to Nova Shop
const test = base.extend<{ shop: Page }>({
shop: async ({ page }, use) => {
await page.goto('/shop');
await page.getByTestId('username').fill('demo_user');
await page.getByTestId('password').fill('Demo@123');
await page.getByTestId('login-button').click();
await expect(page).toHaveURL(/\/shop\/products$/);
await use(page); // the test runs here
await page.evaluate(() => localStorage.removeItem('pa-shop')); // tear-down
},
});
test('the cart badge counts items', async ({ shop }) => {
await shop.getByTestId('add-to-cart-p1').click();
await shop.getByTestId('add-to-cart-p4').click();
await expect(shop.getByTestId('cart-badge')).toHaveText('2');
});
test('a sold-out product cannot be added', async ({ shop }) => {
await expect(shop.getByTestId('sold-out-p3')).toBeDisabled();
});Deep dive 4Intermediate
Sign in once, reuse it everywhere
Signing in through the UI before every test is slow and tests the login form a hundred times. Do it once in a setup project, save the browser state, and start every other test already signed in.
- ✓storageState saves cookies and localStorage — Nova Shop keeps its session in localStorage
- ✓A setup project runs first; the others list it under dependencies
- ✓Keep the saved file out of version control — it is a credential
import { test as setup, expect } from '@playwright/test';
setup('sign in to Nova Shop once', async ({ page }) => {
await page.goto('/shop');
await page.getByTestId('username').fill('demo_user');
await page.getByTestId('password').fill('Demo@123');
await page.getByTestId('login-button').click();
await expect(page).toHaveURL(/\/shop\/products$/);
await page.context().storageState({ path: '.auth/shop.json' });
});import { defineConfig } from '@playwright/test';
export default defineConfig({
use: { baseURL: process.env.BASE_URL ?? 'https://www.practiceqaautomation.com' },
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'signed-in',
testMatch: /signed-in\.spec\.ts/,
dependencies: ['setup'],
use: { storageState: '.auth/shop.json' },
},
],
});import { test, expect } from '@playwright/test';
test('starts on the products page, already signed in', async ({ page }) => {
await page.goto('/shop/products'); // no login step
await expect(page.getByTestId('user-greeting')).toContainText('demo_user');
await expect(page.getByTestId('product-card')).toHaveCount(12);
});Deep dive 5Intermediate
Control the network
Empty lists, server errors and odd data are hard to set up on a real server and easy to fake. page.route lets you answer a request yourself, change a real response, or fail it.
- ✓route.fulfill answers the request with data you choose
- ✓route.fetch gets the real response so you can edit one field
- ✓Register the route before the page makes the request — usually before page.goto
import { test, expect } from '@playwright/test';
test('empty list shows the empty state', async ({ page }) => {
await page.route('**/mock/products.json*', (route) => route.fulfill({ json: [] }));
await page.goto('/dynamic/network-mocking');
await expect(page.getByTestId('net-empty')).toHaveText('No products found.');
await expect(page.getByTestId('net-item')).toHaveCount(0);
});
test('a server error can be retried', async ({ page }) => {
let calls = 0;
await page.route('**/mock/products.json*', async (route) => {
calls++;
if (calls === 1) return route.fulfill({ status: 500, body: 'Internal Server Error' });
return route.continue(); // the retry reaches the real server
});
await page.goto('/dynamic/network-mocking');
await expect(page.getByTestId('net-app').getByRole('alert')).toContainText('(500)');
await page.getByRole('button', { name: 'Try again' }).click();
await expect(page.getByTestId('net-item')).toHaveCount(8);
expect(calls).toBe(2);
});Deep dive 6Advanced
Check the UI against the API
A UI test proves the screen shows a number. A hybrid test proves it is the right number. The request fixture calls an API from the same test, with the same base URL.
- ✓request.post sends JSON and returns the status and body — no browser involved
- ✓Nova Shop's quote API uses the same pricing rules as the cart screen
- ✓Compare the two instead of hard-coding totals that break when a price changes
import { test, expect } from '@playwright/test';
test('cart totals in the UI match the quote API', async ({ page, request }) => {
await page.goto('/shop');
await page.getByTestId('username').fill('demo_user');
await page.getByTestId('password').fill('Demo@123');
await page.getByTestId('login-button').click();
await page.getByTestId('add-to-cart-p1').click();
await page.getByTestId('add-to-cart-p4').click();
await page.getByTestId('cart-link').click();
await page.getByTestId('coupon-input').fill('SAVE10');
await page.getByTestId('apply-coupon').click();
const res = await request.post('/api/shop/quote', {
data: { items: [{ id: 'p1', qty: 1 }, { id: 'p4', qty: 1 }], coupon: 'SAVE10' },
});
expect(res.status()).toBe(200);
const quote = await res.json();
const usd = (n: number) => '$' + n.toFixed(2);
await expect(page.getByTestId('subtotal')).toHaveText(usd(quote.subtotal));
await expect(page.getByTestId('discount')).toHaveText('−' + usd(quote.discount));
await expect(page.getByTestId('tax')).toHaveText(usd(quote.tax));
await expect(page.getByTestId('total')).toHaveText(usd(quote.total));
});
test('an unknown coupon is rejected by the API too', async ({ request }) => {
const res = await request.post('/api/shop/quote', {
data: { items: [{ id: 'p1', qty: 1 }], coupon: 'ABC123' },
});
expect(res.status()).toBe(422);
expect((await res.json()).error).toContain('not a valid coupon');
});Deep dive 7Advanced
Two roles in one test
Real workflows cross users: a customer orders, a manager ships. test.step keeps a long journey readable in the report, and a small helper switches user without repeating yourself.
- ✓test.step names each phase, so a failure says which part of the journey broke
- ✓Read the order id from the page and reuse it — never hard-code generated data
- ✓The second role's change must be visible to the first role afterwards
import { test, expect, type Page } from '@playwright/test';
async function signIn(page: Page, user: string) {
await page.goto('/shop');
await page.getByTestId('username').fill(user);
await page.getByTestId('password').fill('Demo@123');
await page.getByTestId('login-button').click();
await page.waitForURL(/\/shop\/(products|admin)$/); // signed in before the test moves on
}
test('customer orders, manager ships, customer sees Shipped', async ({ page }) => {
let orderId = '';
await test.step('customer places an order', async () => {
await signIn(page, 'demo_user');
await page.getByTestId('add-to-cart-p4').click();
await page.getByTestId('cart-link').click();
await page.getByTestId('checkout-button').click();
await page.getByTestId('first-name').fill('Asha');
await page.getByTestId('last-name').fill('Rao');
await page.getByTestId('email').fill('asha@example.com');
await page.getByTestId('phone').fill('5551234567');
await page.getByTestId('address').fill('12 Lake Road');
await page.getByTestId('city').fill('Austin');
await page.getByTestId('state').selectOption('Texas');
await page.getByTestId('zip').fill('73301');
await page.getByTestId('payment-cod').check();
await page.getByTestId('accept-terms').check();
await page.getByTestId('place-order').click();
orderId = await page.getByTestId('order-id').innerText();
await page.getByTestId('logout-button').click();
});
await test.step('store manager ships it', async () => {
await signIn(page, 'admin_user');
await expect(page).toHaveURL(/\/shop\/admin$/);
const row = page.getByTestId('admin-order-row').filter({ hasText: orderId });
await expect(row.getByTestId('admin-order-customer')).toHaveText('demo_user');
await row.getByRole('button', { name: 'Mark shipped' }).click();
await expect(row.getByTestId('admin-order-status')).toHaveText('Shipped');
await page.getByTestId('logout-button').click();
});
await test.step('customer sees the new status', async () => {
await signIn(page, 'demo_user');
await page.goto('/shop/orders');
const row = page.getByTestId('order-row').filter({ hasText: orderId });
await expect(row.getByTestId('order-status')).toHaveText('Shipped');
});
});
test('a customer cannot open the admin page', async ({ page }) => {
await signIn(page, 'demo_user');
await expect(page).toHaveURL(/\/shop\/products$/);
await page.goto('/shop/admin');
await expect(page.getByTestId('access-denied')).toBeVisible();
});Deep dive 8Advanced
Control time
Session timeouts, hold timers and countdowns make tests slow or flaky if you wait for them. page.clock moves the browser's time forward so a ten-minute timeout takes milliseconds.
- ✓Install the clock before the page loads so every timer uses it
- ✓fastForward jumps ahead and fires the timers that were due
- ✓TrustBank signs you out after ten idle minutes — a perfect target
import { test, expect } from '@playwright/test';
test('the session expires after ten idle minutes', async ({ page }) => {
await page.clock.install();
await page.goto('/apps/bank');
await page.getByTestId('customer-id').fill('100200300');
await page.getByTestId('bank-password').fill('Bank@123');
await page.getByTestId('login-submit').click();
await page.getByTestId('security-answer').fill('greenwood');
await page.getByTestId('verify-answer').click();
await expect(page.getByTestId('customer-name')).toHaveText('Amir Khan');
await page.clock.fastForward('10:01'); // ten minutes and a second, instantly
await expect(page.getByTestId('session-expired')).toContainText('Your session expired');
await expect(page.getByTestId('bank-login-form')).toBeVisible(); // back on the sign-in page
});Deep dive 9Intermediate
A config you can debug with
When a test fails in CI you can't watch it. Traces, screenshots and retries turn “it failed” into a recording you can step through.
- ✓trace: 'on-first-retry' records a full trace only when a test fails once — cheap and enough
- ✓Open one with npx playwright show-trace, or from the HTML report
- ✓Projects run the same tests on desktop and on a phone viewport
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.BASE_URL ?? 'https://www.practiceqaautomation.com',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'phone', use: { ...devices['Pixel 7'] } },
],
});