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

Parameterize tests

Instead of copying the same test 5 times for different inputs, run it against a data array. Two levels: test-level (forEach over cases) and project-level (different use options per project run). The forEach approach is 80% of what you need.

Test-level: forEach over a data array

The simplest pattern: loop over an array of inputs before test(). Playwright generates a separate test for each item. The test name includes the parameter so you can see exactly which case failed.

I use this when testing order status transitions — the same flow (create → update → check), different status values.

ts
// Тест статус-badge для кожного статусу замовлення
const orderStatuses = [
  { status: 'pending',   badge: 'Pending',    color: 'yellow' },
  { status: 'shipped',   badge: 'Shipped',    color: 'blue'   },
  { status: 'delivered', badge: 'Delivered',  color: 'green'  },
  { status: 'cancelled', badge: 'Cancelled',  color: 'red'    },
]

for (const { status, badge, color } of orderStatuses) {
  test(`order with status "${status}" shows correct badge`, async ({ page }) => {
    // Мокуємо конкретний статус
    await page.route('*/**/api/orders/42', async route => {
      await route.fulfill({ json: { id: 'ORD-042', status } })
    })

    await page.goto('/orders/42')

    // Назва тесту включає status — зразу видно який впав
    await expect(page.getByTestId('status-badge')).toHaveText(badge)
    await expect(page.getByTestId('status-badge')).toHaveCSS('background-color', color)
  })
}

Hooks with parameterized tests

If you add beforeEach outside the loop, it runs once before the whole group. If you want hooks per iteration, put them inside test.describe() inside the loop.

ts
// beforeEach ПОЗА forEach — виконується один раз для всіх тестів
test.beforeEach(async ({ page }) => {
  await page.goto('/orders')
})

for (const status of ['pending', 'shipped', 'delivered']) {
  test(`filter by ${status}`, async ({ page }) => {
    await page.getByRole('combobox', { name: 'Status' }).selectOption(status)
    // Перевіряємо що відображаються тільки потрібні замовлення
    await expect(page.getByTestId('order-row')).not.toHaveCount(0)
  })
}

// --

// beforeEach ВСЕРЕДИНІ describe — виконується для кожної ітерації
for (const status of ['pending', 'shipped']) {
  test.describe(`${status} orders`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`/orders?status=${status}`)
    })

    test('list loads', async ({ page }) => {
      await expect(page.getByTestId('order-row').first()).toBeVisible()
    })

    test('export button is visible', async ({ page }) => {
      await expect(page.getByRole('button', { name: 'Export' })).toBeVisible()
    })
  })
}

Environment variables — credentials and environment switching

Never hardcode credentials in tests. Read them from environment variables. For local dev, use a .env file with dotenv. For CI, set them as repository secrets.

ts
// tests/auth.spec.ts — читаємо зі змінних середовища
test('admin login', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!)
  await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!)
  await page.getByRole('button', { name: 'Sign in' }).click()
  await page.waitForURL('/dashboard')
})
ts
// playwright.config.ts — підключаємо .env перед конфігом
import * as dotenv from 'dotenv'
import { defineConfig } from '@playwright/test'

dotenv.config({ path: '.env.test' })

export default defineConfig({
  use: {
    // Перемикання між staging і production одним env var
    baseURL: process.env.STAGING === '1'
      ? 'https://staging.myapp.com'
      : 'http://localhost:3000',
  },
})

// .env.test
// ADMIN_EMAIL=admin@example.com
// ADMIN_PASSWORD=test-password-123
// STAGING=0
bash
# Локально — читає з .env.test
npx playwright test

# CI — змінні задані як secrets, STAGING=1 перемикає URL
STAGING=1 npx playwright test

# Або через cross-env для кросс-платформеності
cross-env STAGING=1 npx playwright test

Project-level: different options per run

For advanced cases — testing the same feature as different user roles — you can declare a custom fixture option and override it per project. Each project runs the full suite with its own use value.

ts
// fixtures/index.ts — оголошуємо опцію
import { test as base } from '@playwright/test'

export type TestOptions = {
  userRole: 'admin' | 'manager' | 'viewer'
}

export const test = base.extend<TestOptions>({
  // Дефолтне значення + option: true = може бути перевизначена з config
  userRole: ['viewer', { option: true }],
})
ts
// playwright.config.ts — три проекти, три ролі
export default defineConfig({
  projects: [
    {
      name: 'admin',
      use: {
        ...devices['Desktop Chrome'],
        userRole: 'admin',
        storageState: 'playwright/.auth/admin.json',
      },
    },
    {
      name: 'manager',
      use: {
        ...devices['Desktop Chrome'],
        userRole: 'manager',
        storageState: 'playwright/.auth/manager.json',
      },
    },
    {
      name: 'viewer',
      use: {
        ...devices['Desktop Chrome'],
        userRole: 'viewer',
        storageState: 'playwright/.auth/viewer.json',
      },
    },
  ],
})
ts
// tests/permissions.spec.ts — використовує userRole з проекту
import { test, expect } from '../fixtures'

test('delete button visibility by role', async ({ page, userRole }) => {
  await page.goto('/orders/42')

  const deleteButton = page.getByRole('button', { name: 'Delete order' })

  if (userRole === 'admin') {
    await expect(deleteButton).toBeVisible()
  } else {
    await expect(deleteButton).not.toBeVisible()
  }
})