Answer
Scenario: Testing Dynamic Tables with Sort and Filter
Test Sorting
TypeScript
import { test, expect } from '@playwright/test';
test('sort products table by price ascending', async ({ page }) => {
await page.goto('/admin/products');
// Get initial order
const prices = await page.locator('td.price').allTextContents();
const initialPrices = prices.map(p => parseFloat(p.replace('$', '')));
// Click Price column header to sort
await page.getByRole('columnheader', { name: 'Price' }).click();
// Verify sort indicator appears
await expect(page.getByRole('columnheader', { name: 'Price' })).toHaveAttribute('aria-sort', 'ascending');
// Wait for table to update
await page.waitForLoadState('networkidle');
// Verify prices are in ascending order
const sortedPrices = await page.locator('td.price').allTextContents();
const sorted = sortedPrices.map(p => parseFloat(p.replace('$', '')));
for (let i = 0; i < sorted.length - 1; i++) {
expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
}
});
test('sort by clicking column header twice (desc)', async ({ page }) => {
await page.goto('/products');
const nameHeader = page.getByRole('columnheader', { name: 'Name' });
await nameHeader.click(); // Ascending
await nameHeader.click(); // Descending
await expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
const names = await page.locator('td.product-name').allTextContents();
const sortedDesc = [...names].sort((a, b) => b.localeCompare(a));
expect(names).toEqual(sortedDesc);
});
Test Filtering
TypeScript
test('filter products by category', async ({ page }) => {
await page.goto('/products');
const initialCount = await page.locator('tr.product-row').count();
expect(initialCount).toBeGreaterThan(10);
// Apply category filter
await page.getByLabel('Category').selectOption('Electronics');
// Wait for filtered results
await page.waitForLoadState('networkidle');
// Verify all visible rows belong to Electronics
const categories = await page.locator('td.category').allTextContents();
categories.forEach(cat => expect(cat).toBe('Electronics'));
// Verify count reduced
const filteredCount = await page.locator('tr.product-row').count();
expect(filteredCount).toBeLessThan(initialCount);
expect(filteredCount).toBeGreaterThan(0);
});
Test Search/Filter Combined
TypeScript
test('search and sort products', async ({ page }) => {
await page.goto('/products');
// Apply search
await page.getByPlaceholder('Search products...').fill('laptop');
await page.waitForLoadState('networkidle');
// Verify search results
const names = await page.locator('td.product-name').allTextContents();
names.forEach(name => expect(name.toLowerCase()).toContain('laptop'));
// Now sort the results
await page.getByRole('columnheader', { name: 'Price' }).click();
const prices = await page.locator('td.price').allTextContents();
const numPrices = prices.map(p => parseFloat(p.replace(/[$,]/g, '')));
for (let i = 0; i < numPrices.length - 1; i++) {
expect(numPrices[i]).toBeLessThanOrEqual(numPrices[i + 1]);
}
});
Verify API Params with Sorting
TypeScript
test('table sort sends correct API parameters', async ({ page }) => {
const apiCalls: URL[] = [];
await page.route('/api/products*', async route => {
apiCalls.push(new URL(route.request().url()));
await route.continue();
});
await page.goto('/products');
// Click to sort by price descending
await page.getByRole('columnheader', { name: 'Price' }).click(); // asc
await page.getByRole('columnheader', { name: 'Price' }).click(); // desc
// Verify the API call includes sort parameters
const sortCall = apiCalls.find(url => url.searchParams.has('sortBy'));
expect(sortCall?.searchParams.get('sortBy')).toBe('price');
expect(sortCall?.searchParams.get('sortDir')).toBe('desc');
});
Test Pagination After Filter
TypeScript
test('pagination resets after applying filter', async ({ page }) => {
await page.goto('/products?page=3');
await expect(page.locator('.pagination .current')).toHaveText('3');
// Apply filter
await page.getByLabel('In Stock Only').check();
// Pagination should reset to page 1
await expect(page.locator('.pagination .current')).toHaveText('1');
});
