# 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