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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
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.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.
Clean up test data via API. Use
test.afterEachortest.afterAllwith therequestfixture to delete resources created during tests, so tests remain independent and repeatable.Prefer
getByRole('row', \{ name: ... \})for table operations. This targets accessible row content and is resilient to column reordering. Avoid relying onnth()indices for specific data rows since ordering can change.
Related
recipes/search-and-filter.md-- Filter and search within resource listsrecipes/file-upload-download.md-- Upload files as part of resource creationpatterns/page-objects.md-- Encapsulate CRUD forms in page objectsfoundations/selectors.md-- Best practices for selecting table cells and form fields