PracticeQAAutomation logoPracticeQAAutomation
Pw

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.

Language

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
locators.spec.ts
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);
});

Runs against: Nova Shop →

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?”
waiting.spec.ts
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);
});

Runs against: Nova Shop →

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
fixtures.spec.ts
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();
});

Runs against: Nova Shop →

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
auth.setup.ts
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' });
});
playwright.config.ts
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' },
    },
  ],
});
signed-in.spec.ts
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);
});

Runs against: Nova Shop →

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
network.spec.ts
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);
});

Runs against: Network mocking →

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
hybrid.spec.ts
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');
});

Runs against: Shop & bank APIs →

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
roles.spec.ts
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();
});

Runs against: Nova Shop admin →

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
clock.spec.ts
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
});

Runs against: TrustBank →

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
playwright.config.ts
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'] } },
  ],
});

Runs against: Framework & CI →

Practicing another tool?