mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
945 lines
31 KiB
Markdown
Executable file
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
|