# 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
