[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 leo
leo

AI Automation Tester.

Email :

Website :

About Nguyen Duc Chung

Senior Automation Test Engineer with 9 years of experience in software testing, specializing in automation testing for banking systems. Experienced in building frameworks from scratch using Katalon, Appium, Playwright, and SerenityBDD. Passionate about researching and applying AI to automated testing processes.