IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Advanced·23 / 24

Best Practices

A collection of rules I keep coming back to when reviewing Playwright test suites — things that make tests survive refactors, run reliably on CI, and stay readable months later.

Test what the user sees, not how it's built

The most resilient tests click buttons by label, check text that users read, and don't care about CSS classes or component internals. When a developer renames class="btn-primary" to class="button-filled", your tests shouldn't break — and they won't if you wrote them against visible behavior.

ts
// ❌ Крихкі локатори — ламаються при рефакторингу
await page.locator('.btn-primary.submit-order').click()
await expect(page.locator('#order-success-msg')).toBeVisible()

// ✅ Стійкі локатори — описують те що бачить користувач
await page.getByRole('button', { name: 'Оформити замовлення' }).click()
await expect(page.getByText('Замовлення прийнято')).toBeVisible()

Locator priority: role > text > test-id > css

Playwright recommends this order for locators, from most resilient to least:

  1. getByRole() — tests accessibility and behavior at once
  2. getByText() / getByLabel() — tied to visible content
  3. getByTestId() — stable explicit marker, add when role/text aren't enough
  4. CSS/XPath — last resort, use when nothing else works

When you need getByTestId(), agree with the dev team on a convention. I use data-testid as the attribute name — Playwright uses it by default, and it's easy to grep for in the codebase.

ts
// getByRole — найкраще для інтерактивних елементів
await page.getByRole('button', { name: 'Зберегти' }).click()
await page.getByRole('link', { name: 'Замовлення' }).click()
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')

// getByLabel — для форм
await page.getByLabel('Пароль').fill('secret123')

// getByTestId — коли немає стабільного тексту
await page.getByTestId('order-status-badge').click()

// Ланцюжки — звужуємо до конкретної картки
const orderCard = page.getByRole('article').filter({ hasText: 'ORD-001' })
await orderCard.getByRole('button', { name: 'Деталі' }).click()

Keep tests independent

Each test should run correctly regardless of which tests ran before it or whether it runs in parallel. If test B relies on data that test A created, you have a hidden dependency — and when tests run in a different order, B breaks for no obvious reason.

Practical rule: if you can't run a test in isolation with npx playwright test --grep "test name" and have it pass, it's not truly isolated.

ts
// ❌ Тести залежать один від одного
test('create order', async ({ page }) => {
  // Створює замовлення — тест A
  await page.goto('/orders/new')
  await page.getByRole('button', { name: 'Підтвердити' }).click()
})

test('see order in list', async ({ page }) => {
  // ❌ Якщо "create order" не запустився — цей тест впаде
  await page.goto('/orders')
  await expect(page.getByRole('row')).toHaveCount(1)
})

// ✅ Кожен тест незалежний — сам створює свої дані
test('see order in list', async ({ page, request }) => {
  // Створюємо замовлення через API (швидко, без UI)
  await request.post('/api/orders', { data: { item: 'Laptop', qty: 1 } })

  await page.goto('/orders')
  await expect(page.getByRole('row')).toHaveCount(1)
})

Never hardcode waits

await page.waitForTimeout(2000) is a 2-second gamble: too short on a slow CI machine, too long on a fast local machine. It makes tests slow, flaky, and hard to maintain. Playwright's auto-waiting and explicit assertions handle timing correctly — use those instead.

ts
// ❌ Хардкодна затримка
await page.waitForTimeout(2000)
await page.click('#submit')

// ✅ Явне очікування стану
await expect(page.getByRole('button', { name: 'Зберегти' })).toBeEnabled()
await page.getByRole('button', { name: 'Зберегти' }).click()

// ✅ Чекаємо поки мережевий запит завершиться
const responsePromise = page.waitForResponse('**/api/orders')
await page.getByRole('button', { name: 'Оновити' }).click()
await responsePromise

// ✅ Чекаємо поки елемент з'явиться
await expect(page.getByText('Завантаження...')).toBeHidden()
await expect(page.getByRole('table')).toBeVisible()

Mock external services

Third-party APIs, payment gateways, SMS providers — don't call them in tests. They're slow, rate-limited, cost money, and can return unexpected responses. Mock them with page.route() and return exactly the response your test needs.

ts
test('payment success flow', async ({ page }) => {
  // Підмінюємо Stripe — не витрачаємо реальну картку
  await page.route('**/stripe.com/**', route =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ status: 'succeeded', id: 'pi_test_123' }),
    })
  )

  await page.goto('/checkout')
  await page.getByRole('button', { name: 'Оплатити' }).click()
  await expect(page.getByText('Оплата успішна')).toBeVisible()
})

Use Page Objects for repeated flows

If login, navigation, or form filling appears in 5+ tests, extract it to a Page Object. When the UI changes — you update one class, not 20 test files. Keep Page Objects thin: just locators and actions, no assertions. Assertions belong in tests.

ts
// pages/OrdersPage.ts
export class OrdersPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/orders')
  }

  async filterByStatus(status: 'pending' | 'shipped' | 'delivered') {
    await this.page.getByRole('combobox', { name: 'Статус' }).selectOption(status)
  }

  orderRow(orderId: string) {
    return this.page.getByRole('row').filter({ hasText: orderId })
  }
}

// tests/orders.spec.ts
test('filter shows only pending orders', async ({ page }) => {
  const orders = new OrdersPage(page)
  await orders.goto()
  await orders.filterByStatus('pending')
  await expect(orders.orderRow('ORD-001')).toBeVisible()
  await expect(orders.orderRow('ORD-002')).toBeHidden()
})

CI-specific tips

A few things that save pain on CI:

  1. Always run in headless mode — headed mode needs a display server
  2. Set retries: 1 in config to catch flakiness without masking real bugs
  3. Use --reporter=github on GitHub Actions for inline test annotations
  4. Save traces on failure (trace: 'on-first-retry') — you'll thank yourself when debugging
  5. Pin browser versions in package.json@playwright/test version determines browser binaries
ts
// playwright.config.ts — типовий CI конфіг
export default defineConfig({
  retries: process.env.CI ? 1 : 0,
  use: {
    headless: true,
    screenshot: 'only-on-failure',
    trace: 'on-first-retry',
    video: 'on-first-retry',
  },
  reporter: process.env.CI
    ? [['github'], ['html', { open: 'never' }]]
    : 'list',
})