Skip to main content
Documentation

UI Automation Guide

Learn industry best practices for UI test automation. This guide covers framework selection, Page Object Model, reliable locator strategies, and writing maintainable Playwright tests against the current Task Manager flows, including modal-based create/edit/details screens and the board view.

πŸ’‘ Note: The test automation approach is the same for both the SSR and SPA versions. The main difference is tooling around the rendered UI, not the user flows themselves. See SSR vs SPA for the practical AI-tooling difference.

Current Task Manager Flows

  • Create, edit, and detail screens are modal-first: The SSR app exposes them as URL states such as ?taskModal=create, ?taskModal=edit&taskId=123, and ?taskModal=detail&taskId=123.
  • The board view is a first-class mode: Use ?view=board when you want to verify drag-and-drop and status-column behaviour.
  • Labels and comments are part of normal task workflows: They should be covered in both list/detail assertions and edit-flow automation.
  • Prefer asserting state, not implementation: Verify modal visibility, query-string changes, and updated task content instead of coupling tests to internal component structure.

Framework Selection

Choose your UI automation framework based on your project needs. All three are suitable for testing the Task Manager.

FeaturePlaywrightSeleniumCypress
Speed⚑ Fast🐒 Slower⚑ Fast
Auto-waitingβœ… Built-in❌ Manualβœ… Built-in
BrowsersChrome, Firefox, SafariAll major browsersChrome, Edge only
API Testingβœ… Built-in❌ Separate library neededβœ… Built-in
Multi-languageJS/TS, Python, Java, C#All major languagesJS/TS only
Learning CurveπŸ“— EasyπŸ“• ModerateπŸ“— Easy

Page Object Model (POM)

The Page Object Model is the foundation of maintainable UI test automation. Each page or component is represented by a class that encapsulates locators and interactions, keeping tests clean and selectors in one place.

Why Use POM?

  • πŸ”§ Easy maintenance: When UI changes, update only one class
  • ♻️ Reusable code: Share page methods across many tests
  • πŸ“– Readable tests: Tests read like business language
  • πŸ›‘οΈ Encapsulation: Hide implementation details from tests

Task Manager Page Object

typescript
import { type Page, type Locator, expect } from '@playwright/test';

export class TaskManagerPage {
  readonly page: Page;

  // Locators defined once, reused everywhere
  readonly searchInput: Locator;
  readonly addTaskButton: Locator;
  readonly statusFilter: Locator;
  readonly boardViewLink: Locator;
  readonly taskRows: Locator;

  constructor(page: Page) {
    this.page = page;
    this.searchInput = page.getByPlaceholder('Search tasks...');
    this.addTaskButton = page.getByRole('link', { name: /New Task|Add Task/i });
    this.statusFilter = page.locator('select.status-filter');
    this.boardViewLink = page.getByRole('link', { name: /Board/i });
    this.taskRows = page.locator('.task-table tbody tr');
  }

  async goto() {
    await this.page.goto('/task-manager');
  }

  async searchTasks(query: string) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
  }

  async filterByStatus(status: 'TODO' | 'IN_PROGRESS' | 'DONE') {
    await this.statusFilter.selectOption(status);
  }

  async getTaskCount(): Promise<number> {
    return this.taskRows.count();
  }

  async openCreateModal() {
    await this.addTaskButton.click();
    await expect(this.page).toHaveURL(/taskModal=create/);
  }

  async openBoardView() {
    await this.boardViewLink.click();
    await expect(this.page).toHaveURL(/view=board/);
  }
}

Task Form Page Object

typescript
export class TaskFormPage {
  readonly page: Page;

  readonly titleInput: Locator;
  readonly descriptionInput: Locator;
  readonly statusSelect: Locator;
  readonly prioritySelect: Locator;
  readonly labelsInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.titleInput = page.getByLabel('Title');
    this.descriptionInput = page.getByLabel('Description');
    this.statusSelect = page.getByLabel('Status');
    this.prioritySelect = page.getByLabel('Priority');
    this.labelsInput = page.getByLabel('Labels');
    this.submitButton = page.getByRole('button', { name: 'Save' });
  }

  async fillAndSubmit(task: {
    title: string;
    description?: string;
    status?: string;
    priority?: string;
    labels?: string;
  }) {
    await this.titleInput.fill(task.title);
    if (task.description) await this.descriptionInput.fill(task.description);
    if (task.status) await this.statusSelect.selectOption(task.status);
    if (task.priority) await this.prioritySelect.selectOption(task.priority);
    if (task.labels) await this.labelsInput.fill(task.labels);
    await this.submitButton.click();
  }
}

Using Page Objects in Tests

typescript
import { test, expect } from '@playwright/test';
import { TaskManagerPage } from './pages/TaskManagerPage';
import { TaskFormPage } from './pages/TaskFormPage';

test('create and find task', async ({ page }) => {
  const taskManager = new TaskManagerPage(page);
  const taskForm = new TaskFormPage(page);

  await taskManager.goto();
  await taskManager.openCreateModal();

  await taskForm.fillAndSubmit({
    title: 'Automated Test Task',
    description: 'Created by Playwright',
    status: 'TODO',
    priority: 'HIGH',
    labels: 'playwright,docs',
  });

  await expect(page).toHaveURL(//task-manager(?|$)/);
  await expect(page.getByText('Automated Test Task')).toBeVisible();
  await expect(page.getByText('playwright')).toBeVisible();
});

Locator Strategies

Choosing the right locator is critical for test stability. Prefer user-facing attributes over internal CSS classes.

Priority Order (best to worst)

typescript
// βœ… Best: Accessible roles and names (user-visible, resilient)
page.getByRole('button', { name: 'Save Task' })
page.getByRole('link', { name: /New Task|Add Task/i })
page.getByLabel('Task Title')

// βœ… Good: Placeholder text or visible text
page.getByPlaceholder('Search tasks...')
page.getByText('No tasks found')

// βœ… Good: Test IDs (stable, explicitly for tests)
page.getByTestId('task-row-42')
page.getByTestId('submit-btn')

// ⚠️ Acceptable: Unique CSS class
page.locator('.task-table tbody tr')

// ❌ Avoid: Fragile - breaks on layout changes
page.locator('div > div:nth-child(3) > button')
page.locator('#root > main > section > ul > li:first-child')

Handling Asynchronous Behavior

Modern web apps load data asynchronously. Playwright's auto-wait handles most cases, but knowing explicit wait patterns is important for complex scenarios.

Playwright Auto-Wait (recommended)

typescript
// Playwright automatically waits for elements to be actionable
await page.getByRole('button', { name: 'Delete' }).click();        // waits until visible + enabled
await page.getByPlaceholder('Search tasks...').fill('API tests');  // waits until editable
await expect(page.getByText('Task deleted')).toBeVisible();        // waits until visible

Explicit Waits for Dynamic Content

typescript
// Wait for a specific element to appear after an action
await page.getByRole('button', { name: 'Load More' }).click();
await page.waitForSelector('.task-table tbody tr:nth-child(21)');

// Wait for network request to complete
await Promise.all([
  page.waitForResponse(resp =>
    resp.url().includes('/api/tasks') && resp.status() === 200
  ),
  page.getByRole('button', { name: 'Refresh' }).click(),
]);

// Wait for loading indicator to disappear
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });

Common Anti-Patterns to Avoid

typescript
// ❌ Hard-coded waits β€” flaky and slow
await page.waitForTimeout(3000);

// ❌ Storing element references across actions β€” causes stale element errors
const btn = await page.$('.delete-btn');
await page.click('.refresh'); // DOM changes
await btn.click();            // Stale!

// βœ… Re-query after DOM changes
await page.click('.refresh');
await page.locator('.delete-btn').click(); // Fresh query

Test Structure Best Practices

Isolate Tests with Setup and Teardown

typescript
import { test, expect } from '@playwright/test';
import { TaskManagerPage } from './pages/TaskManagerPage';

test.describe('Task CRUD Operations', () => {
  let taskManager: TaskManagerPage;

  test.beforeEach(async ({ page }) => {
    taskManager = new TaskManagerPage(page);
    await taskManager.goto();
  });

  // Each test starts clean β€” no dependency on test order

  test('should display task list', async ({ page }) => {
    const count = await taskManager.getTaskCount();
    expect(count).toBeGreaterThan(0);
  });

  test('should filter tasks by status', async ({ page }) => {
    await taskManager.filterByStatus('TODO');
    const rows = page.locator('.task-table tbody tr');
    await expect(rows.first().locator('.status-badge')).toContainText('TODO');
  });
});

Test Data via API (Fast Setup)

typescript
import { test, expect } from '@playwright/test';

// Use API to set up and clean up test data β€” much faster than UI navigation
test('edit task title', async ({ page, request }) => {
  // Create task via API (fast)
  const createResp = await request.post('/api/v1/tasks', {
    data: {
      title: 'Original Title',
      status: 'TODO',
      priority: 'URGENT',
      labels: ['ui', 'setup'],
    },
  });
  const task = await createResp.json();

  // Test the UI interaction using the modal route state
  await page.goto(`/task-manager?taskModal=edit&taskId=${task.id}`);
  await page.getByLabel('Title').fill('Updated Title');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('Updated Title')).toBeVisible();

  // Cleanup via API (fast)
  await request.delete(`/api/v1/tasks/${task.id}`);
});

Assertions Best Practices

typescript
// βœ… Use web-first assertions β€” they auto-retry until passing or timeout
await expect(page.getByText('Task created')).toBeVisible();
await expect(page.getByRole('row')).toHaveCount(5);
await expect(page.getByLabel('Status')).toHaveValue('TODO');

// βœ… Test URL state changes for modal/board flows
await expect(page).toHaveURL(/taskModal=create/);
await expect(page).toHaveURL(/view=board/);
await expect(page).toHaveTitle(/Task Manager/);

// βœ… Verify element state
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled();

Complete CRUD Test Suite

typescript
import { test, expect } from '@playwright/test';
import { TaskManagerPage } from './pages/TaskManagerPage';
import { TaskFormPage } from './pages/TaskFormPage';

test.describe('Task Manager - Full CRUD', () => {
  test('create a new task in the modal flow', async ({ page }) => {
    const taskManager = new TaskManagerPage(page);
    await taskManager.goto();
    await taskManager.openCreateModal();

    const form = new TaskFormPage(page);
    await form.fillAndSubmit({
      title: 'Write Playwright tests',
      description: 'Cover all CRUD operations',
      status: 'TODO',
      priority: 'URGENT',
      labels: 'playwright,regression',
    });

    await expect(page).toHaveURL(//task-manager(?|$)/);
    await expect(page.getByText('Write Playwright tests')).toBeVisible();
    await expect(page.getByText('regression')).toBeVisible();
  });

  test('search filters task list', async ({ page }) => {
    const taskManager = new TaskManagerPage(page);
    await taskManager.goto();
    await taskManager.searchTasks('Playwright');

    const rows = page.locator('.task-table tbody tr');
    const count = await rows.count();
    expect(count).toBeGreaterThan(0);

    for (let i = 0; i < count; i++) {
      await expect(rows.nth(i)).toContainText(/playwright/i);
    }
  });

  test('update task status from the edit modal', async ({ page, request }) => {
    const createResp = await request.post('/api/v1/tasks', {
      data: { title: 'Status target', status: 'TODO', priority: 'MEDIUM' },
    });
    const task = await createResp.json();

    const taskManager = new TaskManagerPage(page);
    await page.goto(`/task-manager?taskModal=edit&taskId=${task.id}`);

    await page.getByLabel('Status').selectOption('IN_PROGRESS');
    await page.getByRole('button', { name: 'Save' }).click();

    await taskManager.searchTasks('Status target');
    await expect(page.locator('.task-table tbody tr').first()).toContainText('In Progress');
  });

  test('board mode exposes draggable columns', async ({ page }) => {
    const taskManager = new TaskManagerPage(page);
    await taskManager.goto();
    await taskManager.openBoardView();

    await expect(page.getByText('TODO')).toBeVisible();
    await expect(page.getByText('In Progress')).toBeVisible();
    await expect(page.getByText('Done')).toBeVisible();
  });

  test('delete a task', async ({ page }) => {
    const taskManager = new TaskManagerPage(page);
    await taskManager.goto();

    const initialCount = await taskManager.getTaskCount();
    const firstRow = page.locator('.task-table tbody tr').first();

    // Handle confirmation dialog
    page.once('dialog', dialog => dialog.accept());
    await firstRow.getByRole('button', { name: 'Delete' }).click();

    await expect(page.locator('.task-table tbody tr')).toHaveCount(initialCount - 1);
  });
});

Playwright Configuration

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,       // Retry flaky tests on CI
  workers: process.env.CI ? 1 : undefined,

  use: {
    baseURL: 'http://localhost:3000',
    screenshot: 'only-on-failure',        // Capture screenshots on failure
    video: 'retain-on-failure',           // Record video on failure
    trace: 'on-first-retry',              // Trace for retry debugging
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile',   use: { ...devices['iPhone 13'] } },
  ],
});

Best Practices Summary

  • βœ… Use Page Object Model: Centralise locators and interactions
  • βœ… Prefer role/label locators: More resilient than CSS selectors
  • βœ… Use web-first assertions: They auto-retry, reducing flakiness
  • βœ… Set up test data via API: Faster than navigating through UI
  • βœ… One assertion focus per test: Clear failure messages
  • βœ… Avoid hardcoded waits: Use event/state-based waits
  • βœ… Run tests in parallel: Use fullyParallel: true
  • βœ… Capture screenshots on failure: Easier debugging
  • βœ… Test responsiveness: Use viewport settings for mobile