SurfSense/.cursor/skills/playwright-testing/crud-testing.md
2026-05-04 13:54:13 +05:30

945 lines
31 KiB
Markdown
Executable file

# 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