IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Intermediate·2 / 27

Retries

Retries exist for flaky tests — ones that sometimes pass and sometimes fail without code changes. My rule: fix the root cause first, add retries second. Retries mask real problems if overused. On CI I set retries: 2. Locally, retries: 0 — if it fails, I want to know immediately.

Configure retries

Set retries globally in config, or override with --retries CLI flag. When a test fails, Playwright retries it in a new worker process with a fresh browser — no shared state from the previous attempt.

ts
// playwright.config.ts
export default defineConfig({
  // На CI — 2 повтори; локально — 0
  retries: process.env.CI ? 2 : 0,
})
bash
# Перевизначити через CLI (корисно для дебагу)
npx playwright test --retries=3

# Запустити з повторами для одного файлу
npx playwright test orders.spec.ts --retries=2

How Playwright classifies tests after retries

Retry classification: passed = first attempt only, flaky = later retry succeeded, failed = all attempts failed

The HTML report shows three categories. The "flaky" category is especially useful — it means the test is not consistently reliable and should be investigated.

text
Після запуску з retries: 2:

✓ passed   — пройшов з першого разу
⚠ flaky    — впав на першому запуску, пройшов після повтору
✗ failed   — впав і всі повтори теж впали

Detect retry inside a test

testInfo.retry is the retry attempt number — 0 on the first run, 1 on the first retry, etc. Use it to clear server state between retries, or to do extra logging on the retry attempt.

ts
test('create order', async ({ page }, testInfo) => {
  // Якщо це повтор — очистити сміття від попередньої спроби
  if (testInfo.retry > 0) {
    await page.request.delete('/api/test/cleanup-orders')
    console.log(`Retry #${testInfo.retry} — cleaned up previous test data`)
  }

  await page.goto('/orders/new')
  await page.getByLabel('Item').fill('Laptop Stand')
  await page.getByRole('button', { name: 'Create' }).click()
  await expect(page.getByTestId('order-created-banner')).toBeVisible()
})

// Або в фікстурі — очищення відбувається централізовано
export const test = base.extend({
  page: async ({ page }, use, testInfo) => {
    if (testInfo.retry > 0) {
      await page.request.delete('/api/test/reset')
    }
    await use(page)
  },
})

Serial mode — dependent tests that must run in order

By default, tests in a file run independently — if one fails, others still run normally. test.describe.configure({ mode: 'serial' }) changes this: if one test fails, all subsequent tests in the group are skipped. When retrying, the entire group restarts from the beginning.

Use serial mode only when tests genuinely depend on each other — like a multi-step workflow where step 2 can't run without step 1 completing. For most tests, keep them independent.

ts
test.describe('order creation flow', () => {
  // Вся група — sequential; якщо один впав — решта пропускається
  test.describe.configure({ mode: 'serial' })

  let createdOrderId: string

  test('create order', async ({ page }) => {
    await page.goto('/orders/new')
    await page.getByLabel('Item').fill('Laptop Stand')
    await page.getByRole('button', { name: 'Create' }).click()

    await page.waitForURL(/\/orders\/\d+/)
    // Зберегти ID для наступного тесту
    createdOrderId = page.url().match(/\/orders\/(\d+)/)?.[1] ?? ''
  })

  test('update order status', async ({ page }) => {
    // Цей тест залежить від попереднього (createdOrderId)
    await page.goto(`/orders/${createdOrderId}`)
    await page.getByRole('combobox', { name: 'Status' }).selectOption('shipped')
    await page.getByRole('button', { name: 'Save' }).click()
    await expect(page.getByTestId('status-badge')).toHaveText('Shipped')
  })

  test('verify order in list', async ({ page }) => {
    await page.goto('/orders')
    await expect(
      page.getByTestId('order-row').filter({ hasText: createdOrderId })
        .getByTestId('status-badge')
    ).toHaveText('Shipped')
  })
})