# CRUD Testing Recipes

> **When to use**: You need to test create, read, update, or delete operations on any resource -- forms, tables, lists, cards, inline edits, or bulk actions.

---

## Recipe 1: Creating a Resource (Fill Form, Submit, Verify)

### Complete Example

**TypeScript**

```typescript
import { test, expect } from '@playwright/test';

test('creates a new product via form', async ({ page }) => {
  await page.goto('/products');

  // Click the create button
  await page.getByRole('button', { name: 'Add product' }).click();

  // Fill out the creation form
  await page.getByLabel('Product name').fill('Wireless Keyboard');
  await page.getByLabel('SKU').fill('KB-WIRELESS-001');
  await page.getByLabel('Price').fill('79.99');
  await page.getByLabel('Description').fill('Ergonomic wireless keyboard with backlit keys');
  await page.getByLabel('Category').selectOption('Electronics');
  await page.getByLabel('In stock').check();

  // Submit
  await page.getByRole('button', { name: 'Save product' }).click();

  // Verify success notification
  await expect(page.getByRole('alert')).toContainText('Product created successfully');

  // Verify the new item appears in the list
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toContainText('$79.99');
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toContainText('KB-WIRELESS-001');
});

test('shows validation errors for invalid form data', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('button', { name: 'Add product' }).click();

  // Submit empty form
  await page.getByRole('button', { name: 'Save product' }).click();

  // Verify field-level validation messages
  await expect(page.getByText('Product name is required')).toBeVisible();
  await expect(page.getByText('Price is required')).toBeVisible();

  // Fill in invalid data
  await page.getByLabel('Product name').fill('A'); // too short
  await page.getByLabel('Price').fill('-5');

  await page.getByRole('button', { name: 'Save product' }).click();

  await expect(page.getByText('Name must be at least 3 characters')).toBeVisible();
  await expect(page.getByText('Price must be a positive number')).toBeVisible();
});

test('prevents duplicate resource creation', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('button', { name: 'Add product' }).click();

  // Try to create a product with a SKU that already exists
  await page.getByLabel('Product name').fill('Duplicate Product');
  await page.getByLabel('SKU').fill('EXISTING-SKU-001');
  await page.getByLabel('Price').fill('29.99');
  await page.getByRole('button', { name: 'Save product' }).click();

  await expect(page.getByRole('alert')).toContainText(/already exists|duplicate/i);
});
```

**JavaScript**

```javascript
const { test, expect } = require('@playwright/test');

test('creates a new product via form', async ({ page }) => {
  await page.goto('/products');

  await page.getByRole('button', { name: 'Add product' }).click();

  await page.getByLabel('Product name').fill('Wireless Keyboard');
  await page.getByLabel('SKU').fill('KB-WIRELESS-001');
  await page.getByLabel('Price').fill('79.99');
  await page.getByLabel('Description').fill('Ergonomic wireless keyboard with backlit keys');
  await page.getByLabel('Category').selectOption('Electronics');
  await page.getByLabel('In stock').check();

  await page.getByRole('button', { name: 'Save product' }).click();

  await expect(page.getByRole('alert')).toContainText('Product created successfully');
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toContainText('$79.99');
});
```

---

## Recipe 2: Reading / Listing Resources (Table, Cards, Pagination)

### Complete Example

**TypeScript**

```typescript
import { test, expect } from '@playwright/test';

test.describe('reading resources in a table', () => {
  test('displays resources in a table with correct columns', async ({ page }) => {
    await page.goto('/products');

    // Verify table headers
    const headers = page.getByRole('columnheader');
    await expect(headers).toContainText(['Name', 'SKU', 'Price', 'Category', 'Status']);

    // Verify at least one row of data exists
    const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
    await expect(rows.first()).toBeVisible();

    // Verify specific data in a row
    const firstRow = rows.first();
    await expect(firstRow.getByRole('cell').nth(0)).not.toBeEmpty();
    await expect(firstRow.getByRole('cell').nth(2)).toContainText('$');
  });

  test('sorts table by column', async ({ page }) => {
    await page.goto('/products');

    // Click the Price header to sort
    await page.getByRole('columnheader', { name: 'Price' }).click();

    // Get all prices from the table
    const priceCells = page.getByRole('row')
      .filter({ hasNot: page.getByRole('columnheader') })
      .getByRole('cell')
      .nth(2);

    const prices = await priceCells.allTextContents();
    const numericPrices = prices.map((p) => parseFloat(p.replace(/[$,]/g, '')));

    // Verify sorted ascending
    for (let i = 1; i < numericPrices.length; i++) {
      expect(numericPrices[i]).toBeGreaterThanOrEqual(numericPrices[i - 1]);
    }

    // Click again for descending
    await page.getByRole('columnheader', { name: 'Price' }).click();

    const pricesDesc = await priceCells.allTextContents();
    const numericDesc = pricesDesc.map((p) => parseFloat(p.replace(/[$,]/g, '')));

    for (let i = 1; i < numericDesc.length; i++) {
      expect(numericDesc[i]).toBeLessThanOrEqual(numericDesc[i - 1]);
    }
  });

  test('paginates through results', async ({ page }) => {
    await page.goto('/products');

    // Verify pagination controls are visible
    const pagination = page.getByRole('navigation', { name: /pagination/i });
    await expect(pagination).toBeVisible();

    // Get first page content
    const firstPageFirstRow = await page
      .getByRole('row')
      .filter({ hasNot: page.getByRole('columnheader') })
      .first()
      .textContent();

    // Go to page 2
    await pagination.getByRole('button', { name: '2' }).click();

    // Wait for data to load
    await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).first())
      .not.toHaveText(firstPageFirstRow!);

    // Verify page 2 is active
    await expect(pagination.getByRole('button', { name: '2' })).toHaveAttribute(
      'aria-current',
      'page'
    );

    // Verify URL updated with page parameter
    await expect(page).toHaveURL(/[?&]page=2/);
  });

  test('shows correct item count', async ({ page }) => {
    await page.goto('/products');

    // Verify the total count display
    await expect(page.getByText(/showing \d+.+of \d+/i)).toBeVisible();
  });
});

test.describe('reading resources as cards', () => {
  test('displays resources in a card grid', async ({ page }) => {
    await page.goto('/products?view=grid');

    // Verify cards are displayed
    const cards = page.getByRole('article');
    await expect(cards.first()).toBeVisible();

    // Verify card content
    const firstCard = cards.first();
    await expect(firstCard.getByRole('heading')).toBeVisible();
    await expect(firstCard.getByText('$')).toBeVisible();
    await expect(firstCard.getByRole('img')).toBeVisible();
  });

  test('switches between list and grid view', async ({ page }) => {
    await page.goto('/products');

    // Start in table/list view
    await expect(page.getByRole('table')).toBeVisible();

    // Switch to grid
    await page.getByRole('button', { name: /grid view/i }).click();
    await expect(page.getByRole('article').first()).toBeVisible();
    await expect(page.getByRole('table')).not.toBeVisible();

    // Switch back to list
    await page.getByRole('button', { name: /list view/i }).click();
    await expect(page.getByRole('table')).toBeVisible();
  });
});
```

**JavaScript**

```javascript
const { test, expect } = require('@playwright/test');

test('displays resources in a table with correct columns', async ({ page }) => {
  await page.goto('/products');

  const headers = page.getByRole('columnheader');
  await expect(headers).toContainText(['Name', 'SKU', 'Price', 'Category', 'Status']);

  const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  await expect(rows.first()).toBeVisible();
});

test('paginates through results', async ({ page }) => {
  await page.goto('/products');

  const pagination = page.getByRole('navigation', { name: /pagination/i });
  await expect(pagination).toBeVisible();

  const firstPageFirstRow = await page
    .getByRole('row')
    .filter({ hasNot: page.getByRole('columnheader') })
    .first()
    .textContent();

  await pagination.getByRole('button', { name: '2' }).click();

  await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).first())
    .not.toHaveText(firstPageFirstRow);

  await expect(page).toHaveURL(/[?&]page=2/);
});
```

---

## Recipe 3: Updating a Resource (Edit Form, Save, Verify Changes)

### Complete Example

**TypeScript**

```typescript
import { test, expect } from '@playwright/test';

test('updates an existing product', async ({ page }) => {
  await page.goto('/products');

  // Find the target row and click edit
  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  await row.getByRole('button', { name: 'Edit' }).click();

  // Verify the edit form is pre-filled with existing data
  await expect(page.getByLabel('Product name')).toHaveValue('Wireless Keyboard');
  await expect(page.getByLabel('Price')).toHaveValue('79.99');

  // Update the fields
  await page.getByLabel('Product name').clear();
  await page.getByLabel('Product name').fill('Wireless Keyboard Pro');
  await page.getByLabel('Price').clear();
  await page.getByLabel('Price').fill('99.99');

  // Save changes
  await page.getByRole('button', { name: 'Save changes' }).click();

  // Verify success notification
  await expect(page.getByRole('alert')).toContainText('Product updated successfully');

  // Verify the list reflects the changes
  await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toContainText('$99.99');

  // Verify old name is gone
  await expect(page.getByRole('row', { name: /^Wireless Keyboard$/ })).not.toBeVisible();
});

test('edit form preserves data when navigating away and back', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  await row.getByRole('button', { name: 'Edit' }).click();

  // Make changes but do not save
  await page.getByLabel('Product name').clear();
  await page.getByLabel('Product name').fill('Modified Name');

  // Try navigating away
  await page.getByRole('link', { name: 'Dashboard' }).click();

  // Expect unsaved changes warning
  page.on('dialog', async (dialog) => {
    expect(dialog.message()).toContain('unsaved changes');
    await dialog.dismiss(); // Stay on page
  });

  // Verify data is still there after dismissing navigation
  await expect(page.getByLabel('Product name')).toHaveValue('Modified Name');
});

test('cancelling edit discards changes', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  await row.getByRole('button', { name: 'Edit' }).click();

  // Make changes
  await page.getByLabel('Product name').clear();
  await page.getByLabel('Product name').fill('Should Not Save');

  // Cancel
  await page.getByRole('button', { name: 'Cancel' }).click();

  // Verify original data is preserved
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Should Not Save/ })).not.toBeVisible();
});
```

**JavaScript**

```javascript
const { test, expect } = require('@playwright/test');

test('updates an existing product', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  await row.getByRole('button', { name: 'Edit' }).click();

  await expect(page.getByLabel('Product name')).toHaveValue('Wireless Keyboard');
  await expect(page.getByLabel('Price')).toHaveValue('79.99');

  await page.getByLabel('Product name').clear();
  await page.getByLabel('Product name').fill('Wireless Keyboard Pro');
  await page.getByLabel('Price').clear();
  await page.getByLabel('Price').fill('99.99');

  await page.getByRole('button', { name: 'Save changes' }).click();

  await expect(page.getByRole('alert')).toContainText('Product updated successfully');
  await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toContainText('$99.99');
});

test('cancelling edit discards changes', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  await row.getByRole('button', { name: 'Edit' }).click();

  await page.getByLabel('Product name').clear();
  await page.getByLabel('Product name').fill('Should Not Save');

  await page.getByRole('button', { name: 'Cancel' }).click();

  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Should Not Save/ })).not.toBeVisible();
});
```

---

## Recipe 4: Deleting a Resource (Delete, Confirm Dialog, Verify Removal)

### Complete Example

**TypeScript**

```typescript
import { test, expect } from '@playwright/test';

test('deletes a product with confirmation dialog', async ({ page }) => {
  await page.goto('/products');

  // Count items before deletion
  const rowsBefore = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  const countBefore = await rowsBefore.count();

  // Click delete on a specific product
  const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
  await expect(targetRow).toBeVisible();
  await targetRow.getByRole('button', { name: 'Delete' }).click();

  // Confirmation dialog should appear
  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();
  await expect(dialog).toContainText('Are you sure you want to delete');
  await expect(dialog).toContainText('Wireless Keyboard');
  await expect(dialog.getByText(/cannot be undone/i)).toBeVisible();

  // Confirm the deletion
  await dialog.getByRole('button', { name: 'Delete' }).click();

  // Verify the dialog closes
  await expect(dialog).not.toBeVisible();

  // Verify success notification
  await expect(page.getByRole('alert')).toContainText('Product deleted');

  // Verify the item is removed from the list
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).not.toBeVisible();

  // Verify count decreased
  const rowsAfter = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  await expect(rowsAfter).toHaveCount(countBefore - 1);
});

test('cancel delete preserves the resource', async ({ page }) => {
  await page.goto('/products');

  const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
  await targetRow.getByRole('button', { name: 'Delete' }).click();

  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();

  // Cancel the deletion
  await dialog.getByRole('button', { name: 'Cancel' }).click();

  // Dialog closes, item is still present
  await expect(dialog).not.toBeVisible();
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
});

test('handles delete error gracefully', async ({ page }) => {
  // Mock the delete endpoint to fail
  await page.route('**/api/products/*', async (route) => {
    if (route.request().method() === 'DELETE') {
      await route.fulfill({
        status: 409,
        contentType: 'application/json',
        body: JSON.stringify({
          error: 'Cannot delete product with active orders',
        }),
      });
    } else {
      await route.continue();
    }
  });

  await page.goto('/products');

  const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
  await targetRow.getByRole('button', { name: 'Delete' }).click();

  const dialog = page.getByRole('dialog');
  await dialog.getByRole('button', { name: 'Delete' }).click();

  // Verify error is shown to user
  await expect(page.getByRole('alert')).toContainText('Cannot delete product with active orders');

  // Item should still be in the list
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
});
```

**JavaScript**

```javascript
const { test, expect } = require('@playwright/test');

test('deletes a product with confirmation dialog', async ({ page }) => {
  await page.goto('/products');

  const rowsBefore = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  const countBefore = await rowsBefore.count();

  const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
  await targetRow.getByRole('button', { name: 'Delete' }).click();

  const dialog = page.getByRole('dialog');
  await expect(dialog).toBeVisible();
  await expect(dialog).toContainText('Wireless Keyboard');

  await dialog.getByRole('button', { name: 'Delete' }).click();

  await expect(dialog).not.toBeVisible();
  await expect(page.getByRole('alert')).toContainText('Product deleted');
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).not.toBeVisible();

  const rowsAfter = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  await expect(rowsAfter).toHaveCount(countBefore - 1);
});

test('cancel delete preserves the resource', async ({ page }) => {
  await page.goto('/products');

  const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
  await targetRow.getByRole('button', { name: 'Delete' }).click();

  const dialog = page.getByRole('dialog');
  await dialog.getByRole('button', { name: 'Cancel' }).click();

  await expect(dialog).not.toBeVisible();
  await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
});
```

---

## Recipe 5: Inline Editing

### Complete Example

**TypeScript**

```typescript
import { test, expect } from '@playwright/test';

test('edits a field inline by double-clicking', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  const nameCell = row.getByRole('cell').first();

  // Double-click to enter edit mode
  await nameCell.dblclick();

  // The cell should now contain an input
  const inlineInput = nameCell.getByRole('textbox');
  await expect(inlineInput).toBeVisible();
  await expect(inlineInput).toHaveValue('Wireless Keyboard');

  // Edit the value
  await inlineInput.clear();
  await inlineInput.fill('Wireless Keyboard v2');

  // Press Enter to save
  await inlineInput.press('Enter');

  // Verify the cell shows the updated value (no longer an input)
  await expect(nameCell.getByRole('textbox')).not.toBeVisible();
  await expect(nameCell).toHaveText('Wireless Keyboard v2');

  // Verify a success indicator appears
  await expect(page.getByRole('alert')).toContainText(/saved|updated/i);
});

test('cancels inline edit with Escape key', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  const nameCell = row.getByRole('cell').first();

  await nameCell.dblclick();

  const inlineInput = nameCell.getByRole('textbox');
  await inlineInput.clear();
  await inlineInput.fill('Temporary Value');

  // Press Escape to cancel
  await inlineInput.press('Escape');

  // Verify original value is restored
  await expect(nameCell).toHaveText('Wireless Keyboard');
  await expect(nameCell.getByRole('textbox')).not.toBeVisible();
});

test('inline edit with click-away saves changes', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  const nameCell = row.getByRole('cell').first();

  await nameCell.dblclick();

  const inlineInput = nameCell.getByRole('textbox');
  await inlineInput.clear();
  await inlineInput.fill('Keyboard Updated');

  // Click somewhere else on the page to trigger save
  await page.getByRole('heading').first().click();

  await expect(nameCell).toHaveText('Keyboard Updated');
});

test('inline edit shows validation error', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  const priceCell = row.getByRole('cell').nth(2);

  await priceCell.dblclick();

  const inlineInput = priceCell.getByRole('textbox');
  await inlineInput.clear();
  await inlineInput.fill('-10');
  await inlineInput.press('Enter');

  // Validation error should appear inline
  await expect(priceCell.getByText(/positive number|invalid/i)).toBeVisible();

  // Input should remain visible for correction
  await expect(inlineInput).toBeVisible();
});
```

**JavaScript**

```javascript
const { test, expect } = require('@playwright/test');

test('edits a field inline by double-clicking', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  const nameCell = row.getByRole('cell').first();

  await nameCell.dblclick();

  const inlineInput = nameCell.getByRole('textbox');
  await expect(inlineInput).toBeVisible();
  await expect(inlineInput).toHaveValue('Wireless Keyboard');

  await inlineInput.clear();
  await inlineInput.fill('Wireless Keyboard v2');
  await inlineInput.press('Enter');

  await expect(nameCell.getByRole('textbox')).not.toBeVisible();
  await expect(nameCell).toHaveText('Wireless Keyboard v2');
});

test('cancels inline edit with Escape key', async ({ page }) => {
  await page.goto('/products');

  const row = page.getByRole('row', { name: /Wireless Keyboard/ });
  const nameCell = row.getByRole('cell').first();

  await nameCell.dblclick();
  const inlineInput = nameCell.getByRole('textbox');
  await inlineInput.clear();
  await inlineInput.fill('Temporary Value');
  await inlineInput.press('Escape');

  await expect(nameCell).toHaveText('Wireless Keyboard');
});
```

---

## Recipe 6: Bulk Operations

### Complete Example

**TypeScript**

```typescript
import { test, expect } from '@playwright/test';

test('selects multiple items and performs bulk delete', async ({ page }) => {
  await page.goto('/products');

  // Select multiple items via checkboxes
  const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });

  await rows.nth(0).getByRole('checkbox').check();
  await rows.nth(1).getByRole('checkbox').check();
  await rows.nth(2).getByRole('checkbox').check();

  // Verify selection count is shown
  await expect(page.getByText('3 items selected')).toBeVisible();

  // Verify bulk action toolbar appears
  const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
  await expect(bulkToolbar).toBeVisible();

  // Click bulk delete
  await bulkToolbar.getByRole('button', { name: 'Delete selected' }).click();

  // Confirm in dialog
  const dialog = page.getByRole('dialog');
  await expect(dialog).toContainText('Delete 3 products');
  await dialog.getByRole('button', { name: 'Delete' }).click();

  // Verify items removed
  await expect(page.getByRole('alert')).toContainText('3 products deleted');

  // Verify selection count is cleared
  await expect(page.getByText('items selected')).not.toBeVisible();
  await expect(bulkToolbar).not.toBeVisible();
});

test('select all and deselect all', async ({ page }) => {
  await page.goto('/products');

  // Click "Select all" checkbox in the header
  const selectAllCheckbox = page
    .getByRole('row')
    .filter({ has: page.getByRole('columnheader') })
    .getByRole('checkbox');

  await selectAllCheckbox.check();

  // All row checkboxes should be checked
  const rowCheckboxes = page
    .getByRole('row')
    .filter({ hasNot: page.getByRole('columnheader') })
    .getByRole('checkbox');

  const count = await rowCheckboxes.count();
  for (let i = 0; i < count; i++) {
    await expect(rowCheckboxes.nth(i)).toBeChecked();
  }

  await expect(page.getByText(`${count} items selected`)).toBeVisible();

  // Deselect all
  await selectAllCheckbox.uncheck();

  for (let i = 0; i < count; i++) {
    await expect(rowCheckboxes.nth(i)).not.toBeChecked();
  }

  await expect(page.getByText('items selected')).not.toBeVisible();
});

test('bulk status change', async ({ page }) => {
  await page.goto('/products');

  const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  await rows.nth(0).getByRole('checkbox').check();
  await rows.nth(1).getByRole('checkbox').check();

  const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
  await bulkToolbar.getByRole('button', { name: 'Change status' }).click();

  // Select new status from dropdown
  await page.getByRole('menuitem', { name: 'Archived' }).click();

  await expect(page.getByRole('alert')).toContainText('2 products archived');

  // Verify status changed in the rows
  await expect(rows.nth(0)).toContainText('Archived');
  await expect(rows.nth(1)).toContainText('Archived');
});

test('bulk export selected items', async ({ page }) => {
  await page.goto('/products');

  const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  await rows.nth(0).getByRole('checkbox').check();
  await rows.nth(1).getByRole('checkbox').check();

  const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });

  // Start waiting for download before clicking
  const downloadPromise = page.waitForEvent('download');
  await bulkToolbar.getByRole('button', { name: 'Export' }).click();

  const download = await downloadPromise;
  expect(download.suggestedFilename()).toMatch(/products.*\.csv$/);
});
```

**JavaScript**

```javascript
const { test, expect } = require('@playwright/test');

test('selects multiple items and performs bulk delete', async ({ page }) => {
  await page.goto('/products');

  const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
  await rows.nth(0).getByRole('checkbox').check();
  await rows.nth(1).getByRole('checkbox').check();
  await rows.nth(2).getByRole('checkbox').check();

  await expect(page.getByText('3 items selected')).toBeVisible();

  const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
  await bulkToolbar.getByRole('button', { name: 'Delete selected' }).click();

  const dialog = page.getByRole('dialog');
  await expect(dialog).toContainText('Delete 3 products');
  await dialog.getByRole('button', { name: 'Delete' }).click();

  await expect(page.getByRole('alert')).toContainText('3 products deleted');
});

test('select all and deselect all', async ({ page }) => {
  await page.goto('/products');

  const selectAllCheckbox = page
    .getByRole('row')
    .filter({ has: page.getByRole('columnheader') })
    .getByRole('checkbox');

  await selectAllCheckbox.check();

  const rowCheckboxes = page
    .getByRole('row')
    .filter({ hasNot: page.getByRole('columnheader') })
    .getByRole('checkbox');

  const count = await rowCheckboxes.count();
  for (let i = 0; i < count; i++) {
    await expect(rowCheckboxes.nth(i)).toBeChecked();
  }
});
```

---

## Variations

### Create with Multi-Step Wizard

```typescript
test('creates a resource through a multi-step wizard', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('button', { name: 'Add product' }).click();

  // Step 1: Basic info
  await expect(page.getByText('Step 1 of 3')).toBeVisible();
  await page.getByLabel('Product name').fill('Wireless Keyboard');
  await page.getByLabel('Category').selectOption('Electronics');
  await page.getByRole('button', { name: 'Next' }).click();

  // Step 2: Pricing
  await expect(page.getByText('Step 2 of 3')).toBeVisible();
  await page.getByLabel('Price').fill('79.99');
  await page.getByLabel('Tax rate').selectOption('Standard (20%)');
  await page.getByRole('button', { name: 'Next' }).click();

  // Step 3: Review and confirm
  await expect(page.getByText('Step 3 of 3')).toBeVisible();
  await expect(page.getByText('Wireless Keyboard')).toBeVisible();
  await expect(page.getByText('$79.99')).toBeVisible();
  await page.getByRole('button', { name: 'Create product' }).click();

  await expect(page.getByRole('alert')).toContainText('Product created');
});
```

### CRUD with API Verification

```typescript
test('create and verify via API', async ({ page, request }) => {
  await page.goto('/products');
  await page.getByRole('button', { name: 'Add product' }).click();

  await page.getByLabel('Product name').fill('API Verified Product');
  await page.getByLabel('Price').fill('49.99');
  await page.getByRole('button', { name: 'Save product' }).click();

  await expect(page.getByRole('alert')).toContainText('Product created');

  // Also verify via API that the data was persisted correctly
  const response = await request.get('/api/products?search=API+Verified+Product');
  const data = await response.json();

  expect(data.products).toHaveLength(1);
  expect(data.products[0].name).toBe('API Verified Product');
  expect(data.products[0].price).toBe(49.99);
});
```

### Optimistic UI Updates

```typescript
test('shows optimistic update then confirms', async ({ page }) => {
  // Slow down the API response to observe optimistic behavior
  await page.route('**/api/products/*', async (route) => {
    if (route.request().method() === 'PATCH') {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      await route.continue();
    } else {
      await route.continue();
    }
  });

  await page.goto('/products');
  const row = page.getByRole('row', { name: /Wireless Keyboard/ });

  await row.getByRole('button', { name: 'Toggle status' }).click();

  // Verify UI updates immediately (optimistic)
  await expect(row.getByText('Active')).toBeVisible();

  // Verify loading indicator while API catches up
  await expect(row.getByRole('progressbar')).toBeVisible();

  // After API responds, loading indicator disappears
  await expect(row.getByRole('progressbar')).not.toBeVisible({ timeout: 5000 });
  await expect(row.getByText('Active')).toBeVisible();
});
```

---

## Tips

1. **Always verify state after mutations**. After a create, update, or delete, assert that the UI reflects the change. Do not assume the success notification alone means the operation worked. Check that the list, table, or card actually changed.

2. **Use unique identifiers in test data**. Include timestamps or random strings in test data to prevent collisions: `Keyboard-${Date.now()}`. This avoids flaky tests from leftover data.

3. **Test the full lifecycle**. Write a single test that creates, reads, updates, and then deletes a resource. This catches integration issues between operations that isolated tests miss.

4. **Clean up test data via API**. Use `test.afterEach` or `test.afterAll` with the `request` fixture to delete resources created during tests, so tests remain independent and repeatable.

5. **Prefer `getByRole('row', { name: ... })` for table operations**. This targets accessible row content and is resilient to column reordering. Avoid relying on `nth()` indices for specific data rows since ordering can change.

---

## Related

- `recipes/search-and-filter.md` -- Filter and search within resource lists
- `recipes/file-upload-download.md` -- Upload files as part of resource creation
- `patterns/page-objects.md` -- Encapsulate CRUD forms in page objects
- `foundations/selectors.md` -- Best practices for selecting table cells and form fields
