[AI] UI Testing và E2E Testing

Posted by : on

Category : ai   kiro


Chương 18: UI Testing và E2E Testing

Tóm Tắt

Hướng dẫn chi tiết về UI testing và End-to-End testing với Playwright, bao gồm Page Object Model, best practices, visual testing và cross-browser testing.

Playwright Setup

Installation

npm install -D @playwright/test
npx playwright install

Configuration

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

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'test-results/results.json' }],
    ['junit', { outputFile: 'test-results/junit.xml' }]
  ],
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    actionTimeout: 10000,
    navigationTimeout: 30000
  },
  
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] }
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 12'] }
    }
  ],
  
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000
  }
});

Page Object Model

Base Page

// tests/pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';

export class BasePage {
  constructor(protected page: Page) {}

  // Navigation
  async navigate(path: string) {
    await this.page.goto(path);
    await this.waitForPageLoad();
  }

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  async reload() {
    await this.page.reload();
    await this.waitForPageLoad();
  }

  // Element interactions
  async click(selector: string) {
    await this.page.locator(selector).click();
  }

  async fill(selector: string, value: string) {
    await this.page.locator(selector).fill(value);
  }

  async type(selector: string, value: string, delay = 50) {
    await this.page.locator(selector).type(value, { delay });
  }

  async select(selector: string, value: string) {
    await this.page.locator(selector).selectOption(value);
  }

  async check(selector: string) {
    await this.page.locator(selector).check();
  }

  async uncheck(selector: string) {
    await this.page.locator(selector).uncheck();
  }

  // Element queries
  async getText(selector: string): Promise<string> {
    return await this.page.locator(selector).textContent() || '';
  }

  async getValue(selector: string): Promise<string> {
    return await this.page.locator(selector).inputValue();
  }

  async getAttribute(selector: string, attr: string): Promise<string | null> {
    return await this.page.locator(selector).getAttribute(attr);
  }

  async isVisible(selector: string): Promise<boolean> {
    return await this.page.locator(selector).isVisible();
  }

  async isEnabled(selector: string): Promise<boolean> {
    return await this.page.locator(selector).isEnabled();
  }

  async isChecked(selector: string): Promise<boolean> {
    return await this.page.locator(selector).isChecked();
  }

  // Waiting
  async waitForSelector(selector: string, timeout = 5000) {
    await this.page.locator(selector).waitFor({ timeout });
  }

  async waitForText(selector: string, text: string) {
    await expect(this.page.locator(selector)).toContainText(text);
  }

  async waitForUrl(url: string | RegExp) {
    await this.page.waitForURL(url);
  }

  // Screenshots
  async takeScreenshot(name: string) {
    await this.page.screenshot({
      path: `test-results/screenshots/${name}-${Date.now()}.png`,
      fullPage: true
    });
  }

  // Alerts
  async acceptAlert() {
    this.page.once('dialog', dialog => dialog.accept());
  }

  async dismissAlert() {
    this.page.once('dialog', dialog => dialog.dismiss());
  }

  async getAlertText(): Promise<string> {
    return new Promise(resolve => {
      this.page.once('dialog', dialog => {
        resolve(dialog.message());
        dialog.dismiss();
      });
    });
  }

  // Local Storage
  async getLocalStorage(key: string): Promise<string | null> {
    return await this.page.evaluate(
      (k) => localStorage.getItem(k),
      key
    );
  }

  async setLocalStorage(key: string, value: string) {
    await this.page.evaluate(
      ({ k, v }) => localStorage.setItem(k, v),
      { k: key, v: value }
    );
  }

  async clearLocalStorage() {
    await this.page.evaluate(() => localStorage.clear());
  }

  // Cookies
  async getCookie(name: string) {
    const cookies = await this.page.context().cookies();
    return cookies.find(c => c.name === name);
  }

  async setCookie(name: string, value: string) {
    await this.page.context().addCookies([{
      name,
      value,
      domain: 'localhost',
      path: '/'
    }]);
  }

  async clearCookies() {
    await this.page.context().clearCookies();
  }
}

Login Page

// tests/pages/LoginPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';

export class LoginPage extends BasePage {
  // Selectors
  private readonly selectors = {
    emailInput: '[data-testid="email-input"]',
    passwordInput: '[data-testid="password-input"]',
    loginButton: '[data-testid="login-button"]',
    errorMessage: '[data-testid="error-message"]',
    forgotPasswordLink: '[data-testid="forgot-password-link"]',
    signupLink: '[data-testid="signup-link"]',
    rememberMeCheckbox: '[data-testid="remember-me"]',
    showPasswordButton: '[data-testid="show-password"]'
  };

  constructor(page: Page) {
    super(page);
  }

  // Navigation
  async navigate() {
    await super.navigate('/login');
  }

  // Actions
  async enterEmail(email: string) {
    await this.fill(this.selectors.emailInput, email);
  }

  async enterPassword(password: string) {
    await this.fill(this.selectors.passwordInput, password);
  }

  async clickLogin() {
    await this.click(this.selectors.loginButton);
  }

  async login(email: string, password: string) {
    await this.enterEmail(email);
    await this.enterPassword(password);
    await this.clickLogin();
  }

  async checkRememberMe() {
    await this.check(this.selectors.rememberMeCheckbox);
  }

  async clickForgotPassword() {
    await this.click(this.selectors.forgotPasswordLink);
  }

  async clickSignup() {
    await this.click(this.selectors.signupLink);
  }

  async togglePasswordVisibility() {
    await this.click(this.selectors.showPasswordButton);
  }

  // Queries
  async getErrorMessage(): Promise<string> {
    return await this.getText(this.selectors.errorMessage);
  }

  async isErrorVisible(): Promise<boolean> {
    return await this.isVisible(this.selectors.errorMessage);
  }

  async isLoginButtonEnabled(): Promise<boolean> {
    return await this.isEnabled(this.selectors.loginButton);
  }

  // Validations
  async expectErrorMessage(message: string) {
    const error = await this.getErrorMessage();
    expect(error).toContain(message);
  }

  async expectLoginButtonDisabled() {
    const enabled = await this.isLoginButtonEnabled();
    expect(enabled).toBe(false);
  }
}

Dashboard Page

// tests/pages/DashboardPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';

export class DashboardPage extends BasePage {
  private readonly selectors = {
    welcomeMessage: '[data-testid="welcome-message"]',
    userMenu: '[data-testid="user-menu"]',
    logoutButton: '[data-testid="logout-button"]',
    profileLink: '[data-testid="profile-link"]',
    settingsLink: '[data-testid="settings-link"]',
    notificationBadge: '[data-testid="notification-badge"]',
    searchInput: '[data-testid="search-input"]',
    createButton: '[data-testid="create-button"]'
  };

  constructor(page: Page) {
    super(page);
  }

  async navigate() {
    await super.navigate('/dashboard');
  }

  async isDisplayed(): Promise<boolean> {
    return await this.isVisible(this.selectors.welcomeMessage);
  }

  async getWelcomeMessage(): Promise<string> {
    return await this.getText(this.selectors.welcomeMessage);
  }

  async openUserMenu() {
    await this.click(this.selectors.userMenu);
  }

  async logout() {
    await this.openUserMenu();
    await this.click(this.selectors.logoutButton);
  }

  async goToProfile() {
    await this.openUserMenu();
    await this.click(this.selectors.profileLink);
  }

  async goToSettings() {
    await this.openUserMenu();
    await this.click(this.selectors.settingsLink);
  }

  async search(query: string) {
    await this.fill(this.selectors.searchInput, query);
    await this.page.keyboard.press('Enter');
  }

  async clickCreate() {
    await this.click(this.selectors.createButton);
  }

  async getNotificationCount(): Promise<number> {
    const text = await this.getText(this.selectors.notificationBadge);
    return parseInt(text) || 0;
  }
}

Todo List Page

// tests/pages/TodoListPage.ts
import { Page } from '@playwright/test';
import { BasePage } from './BasePage';

export class TodoListPage extends BasePage {
  private readonly selectors = {
    todoInput: '[data-testid="todo-input"]',
    addButton: '[data-testid="add-button"]',
    todoItem: (index: number) => `[data-testid="todo-item-${index}"]`,
    todoCheckbox: (index: number) => `[data-testid="todo-checkbox-${index}"]`,
    todoText: (index: number) => `[data-testid="todo-text-${index}"]`,
    deleteButton: (index: number) => `[data-testid="delete-button-${index}"]`,
    editButton: (index: number) => `[data-testid="edit-button-${index}"]`,
    filterAll: '[data-testid="filter-all"]',
    filterActive: '[data-testid="filter-active"]',
    filterCompleted: '[data-testid="filter-completed"]',
    clearCompleted: '[data-testid="clear-completed"]',
    todoCount: '[data-testid="todo-count"]'
  };

  constructor(page: Page) {
    super(page);
  }

  async navigate() {
    await super.navigate('/todos');
  }

  async addTodo(text: string) {
    await this.fill(this.selectors.todoInput, text);
    await this.click(this.selectors.addButton);
  }

  async toggleTodo(index: number) {
    await this.click(this.selectors.todoCheckbox(index));
  }

  async deleteTodo(index: number) {
    await this.click(this.selectors.deleteButton(index));
  }

  async editTodo(index: number, newText: string) {
    await this.click(this.selectors.editButton(index));
    await this.fill(this.selectors.todoInput, newText);
    await this.page.keyboard.press('Enter');
  }

  async getTodoText(index: number): Promise<string> {
    return await this.getText(this.selectors.todoText(index));
  }

  async isTodoCompleted(index: number): Promise<boolean> {
    return await this.isChecked(this.selectors.todoCheckbox(index));
  }

  async getTodoCount(): Promise<number> {
    const text = await this.getText(this.selectors.todoCount);
    return parseInt(text.match(/\d+/)?.[0] || '0');
  }

  async filterAll() {
    await this.click(this.selectors.filterAll);
  }

  async filterActive() {
    await this.click(this.selectors.filterActive);
  }

  async filterCompleted() {
    await this.click(this.selectors.filterCompleted);
  }

  async clearCompleted() {
    await this.click(this.selectors.clearCompleted);
  }

  async getAllTodos(): Promise<string[]> {
    const todos = await this.page.locator('[data-testid^="todo-text-"]').all();
    return Promise.all(todos.map(t => t.textContent() || ''));
  }
}

E2E Test Examples

Login Flow

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { UserFactory } from '../factories/UserFactory';

test.describe('Login Flow', () => {
  let loginPage: LoginPage;
  let dashboardPage: DashboardPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    dashboardPage = new DashboardPage(page);
    await loginPage.navigate();
  });

  test('should login with valid credentials', async () => {
    // Arrange
    const user = await UserFactory.create({
      email: 'test@example.com',
      password: 'Password123'
    });

    // Act
    await loginPage.login(user.email, 'Password123');

    // Assert
    await expect(dashboardPage.isDisplayed()).resolves.toBe(true);
    const welcomeMsg = await dashboardPage.getWelcomeMessage();
    expect(welcomeMsg).toContain(user.name);
  });

  test('should show error with invalid credentials', async () => {
    // Act
    await loginPage.login('invalid@example.com', 'wrongpassword');

    // Assert
    await expect(loginPage.isErrorVisible()).resolves.toBe(true);
    await loginPage.expectErrorMessage('Invalid credentials');
  });

  test('should validate email format', async () => {
    // Act
    await loginPage.enterEmail('invalid-email');
    await loginPage.enterPassword('password');
    await loginPage.clickLogin();

    // Assert
    await loginPage.expectErrorMessage('Invalid email format');
  });

  test('should remember user when checkbox checked', async ({ page }) => {
    // Arrange
    const user = await UserFactory.create();

    // Act
    await loginPage.login(user.email, 'Password123');
    await loginPage.checkRememberMe();
    await loginPage.clickLogin();

    // Assert
    const cookie = await page.context().cookies();
    expect(cookie.some(c => c.name === 'remember_token')).toBe(true);
  });

  test('should navigate to forgot password', async ({ page }) => {
    // Act
    await loginPage.clickForgotPassword();

    // Assert
    await expect(page).toHaveURL('/forgot-password');
  });

  test('should navigate to signup', async ({ page }) => {
    // Act
    await loginPage.clickSignup();

    // Assert
    await expect(page).toHaveURL('/signup');
  });
});

Todo CRUD Flow

// tests/e2e/todo-crud.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { TodoListPage } from '../pages/TodoListPage';
import { UserFactory } from '../factories/UserFactory';

test.describe('Todo CRUD', () => {
  let loginPage: LoginPage;
  let todoPage: TodoListPage;
  let user: any;

  test.beforeEach(async ({ page }) => {
    // Setup
    loginPage = new LoginPage(page);
    todoPage = new TodoListPage(page);
    user = await UserFactory.create();

    // Login
    await loginPage.navigate();
    await loginPage.login(user.email, 'Password123');
    await todoPage.navigate();
  });

  test('should create new todo', async () => {
    // Act
    await todoPage.addTodo('Buy groceries');

    // Assert
    const todos = await todoPage.getAllTodos();
    expect(todos).toContain('Buy groceries');
  });

  test('should mark todo as completed', async () => {
    // Arrange
    await todoPage.addTodo('Complete task');

    // Act
    await todoPage.toggleTodo(0);

    // Assert
    const isCompleted = await todoPage.isTodoCompleted(0);
    expect(isCompleted).toBe(true);
  });

  test('should delete todo', async () => {
    // Arrange
    await todoPage.addTodo('Delete me');
    const initialCount = await todoPage.getTodoCount();

    // Act
    await todoPage.deleteTodo(0);

    // Assert
    const newCount = await todoPage.getTodoCount();
    expect(newCount).toBe(initialCount - 1);
  });

  test('should edit todo', async () => {
    // Arrange
    await todoPage.addTodo('Original text');

    // Act
    await todoPage.editTodo(0, 'Updated text');

    // Assert
    const text = await todoPage.getTodoText(0);
    expect(text).toBe('Updated text');
  });

  test('should filter todos', async () => {
    // Arrange
    await todoPage.addTodo('Active todo');
    await todoPage.addTodo('Completed todo');
    await todoPage.toggleTodo(1);

    // Act - Filter active
    await todoPage.filterActive();
    let todos = await todoPage.getAllTodos();
    expect(todos).toHaveLength(1);
    expect(todos[0]).toBe('Active todo');

    // Act - Filter completed
    await todoPage.filterCompleted();
    todos = await todoPage.getAllTodos();
    expect(todos).toHaveLength(1);
    expect(todos[0]).toBe('Completed todo');

    // Act - Filter all
    await todoPage.filterAll();
    todos = await todoPage.getAllTodos();
    expect(todos).toHaveLength(2);
  });

  test('should clear completed todos', async () => {
    // Arrange
    await todoPage.addTodo('Todo 1');
    await todoPage.addTodo('Todo 2');
    await todoPage.toggleTodo(0);

    // Act
    await todoPage.clearCompleted();

    // Assert
    const todos = await todoPage.getAllTodos();
    expect(todos).toHaveLength(1);
    expect(todos[0]).toBe('Todo 2');
  });
});

User Registration Flow

// tests/e2e/registration.spec.ts
import { test, expect } from '@playwright/test';
import { SignupPage } from '../pages/SignupPage';
import { DashboardPage } from '../pages/DashboardPage';

test.describe('User Registration', () => {
  let signupPage: SignupPage;
  let dashboardPage: DashboardPage;

  test.beforeEach(async ({ page }) => {
    signupPage = new SignupPage(page);
    dashboardPage = new DashboardPage(page);
    await signupPage.navigate();
  });

  test('should register new user', async () => {
    // Arrange
    const email = `test-${Date.now()}@example.com`;
    const password = 'SecurePass123!';
    const name = 'Test User';

    // Act
    await signupPage.fillRegistrationForm({
      email,
      password,
      confirmPassword: password,
      name
    });
    await signupPage.acceptTerms();
    await signupPage.submit();

    // Assert
    await expect(dashboardPage.isDisplayed()).resolves.toBe(true);
  });

  test('should validate password strength', async () => {
    // Act
    await signupPage.enterPassword('weak');

    // Assert
    await signupPage.expectPasswordStrength('weak');
  });

  test('should validate password match', async () => {
    // Act
    await signupPage.enterPassword('Password123!');
    await signupPage.enterConfirmPassword('DifferentPass123!');
    await signupPage.submit();

    // Assert
    await signupPage.expectErrorMessage('Passwords do not match');
  });
});

Visual Testing

Screenshot Comparison

// tests/e2e/visual.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Visual Regression', () => {
  test('homepage should match snapshot', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveScreenshot('homepage.png');
  });

  test('login page should match snapshot', async ({ page }) => {
    await page.goto('/login');
    await expect(page).toHaveScreenshot('login-page.png');
  });

  test('dashboard should match snapshot', async ({ page }) => {
    // Login first
    await page.goto('/login');
    await page.fill('[data-testid="email-input"]', 'test@example.com');
    await page.fill('[data-testid="password-input"]', 'password');
    await page.click('[data-testid="login-button"]');
    
    // Take screenshot
    await expect(page).toHaveScreenshot('dashboard.png');
  });
});

Element Screenshot

test('button should match snapshot', async ({ page }) => {
  await page.goto('/');
  const button = page.locator('[data-testid="primary-button"]');
  await expect(button).toHaveScreenshot('primary-button.png');
});

Cross-Browser Testing

Browser-Specific Tests

// tests/e2e/cross-browser.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Cross-Browser Compatibility', () => {
  test('should work on all browsers', async ({ page, browserName }) => {
    await page.goto('/');
    
    // Browser-specific logic
    if (browserName === 'webkit') {
      // Safari-specific test
    } else if (browserName === 'firefox') {
      // Firefox-specific test
    }
    
    // Common assertions
    await expect(page.locator('h1')).toBeVisible();
  });
});

Mobile Testing

Mobile-Specific Tests

// tests/e2e/mobile.spec.ts
import { test, expect, devices } from '@playwright/test';

test.use(devices['iPhone 12']);

test.describe('Mobile Experience', () => {
  test('should display mobile menu', async ({ page }) => {
    await page.goto('/');
    
    // Mobile menu should be visible
    const mobileMenu = page.locator('[data-testid="mobile-menu"]');
    await expect(mobileMenu).toBeVisible();
    
    // Desktop menu should be hidden
    const desktopMenu = page.locator('[data-testid="desktop-menu"]');
    await expect(desktopMenu).not.toBeVisible();
  });

  test('should handle touch gestures', async ({ page }) => {
    await page.goto('/todos');
    
    // Swipe to delete
    const todo = page.locator('[data-testid="todo-item-0"]');
    await todo.swipe('left');
    
    const deleteButton = page.locator('[data-testid="delete-button-0"]');
    await expect(deleteButton).toBeVisible();
  });
});

Performance Testing

Measure Page Load Time

// tests/e2e/performance.spec.ts
import { test, expect } from '@playwright/test';

test('homepage should load quickly', async ({ page }) => {
  const startTime = Date.now();
  
  await page.goto('/');
  await page.waitForLoadState('networkidle');
  
  const loadTime = Date.now() - startTime;
  
  expect(loadTime).toBeLessThan(3000); // 3 seconds
});

Lighthouse Integration

import { test } from '@playwright/test';
import { playAudit } from 'playwright-lighthouse';

test('should pass lighthouse audit', async ({ page }) => {
  await page.goto('/');
  
  await playAudit({
    page,
    thresholds: {
      performance: 90,
      accessibility: 90,
      'best-practices': 90,
      seo: 90
    }
  });
});

Best Practices

1. Use Data Test IDs

<!-- ✅ Good -->
<button data-testid="login-button">Login</button>

<!-- ❌ Bad -->
<button class="btn btn-primary">Login</button>

2. Wait for Elements

// ✅ Good
await page.locator('[data-testid="button"]').waitFor();
await page.locator('[data-testid="button"]').click();

// ❌ Bad
await page.locator('[data-testid="button"]').click(); // May fail if not ready

3. Use Page Objects

// ✅ Good
await loginPage.login(email, password);

// ❌ Bad
await page.fill('#email', email);
await page.fill('#password', password);
await page.click('#login-btn');

4. Clean Test Data

test.afterEach(async () => {
  await CleanupHelper.deleteTestData();
});

5. Handle Flaky Tests

// Retry flaky tests
test.describe.configure({ retries: 2 });

// Or use polling
await expect(async () => {
  const text = await page.textContent('[data-testid="status"]');
  expect(text).toBe('Success');
}).toPass({ timeout: 10000 });

Kết Luận

UI và E2E testing với Playwright:

  • ✅ Page Object Model
  • ✅ Cross-browser testing
  • ✅ Mobile testing
  • ✅ Visual testing
  • ✅ Performance testing
  • ✅ Best practices

Chương tiếp theo: Troubleshooting


Bài viết được viết bằng AI 🚀


About Nguyen Chung
Nguyen Chung

Hi I am Nguyen Chung, an Automation Tester.

Email : ndchungict@gmail.com

Website : https://ndchungict.github.io

About Nguyen Chung

Hi, my name is Nguyen Duc Chung. Nice to see you!