CupCode.cc
Section 04 · Module 07 · Lesson 01 · ~15–20 นาที

Page Object Model (POM)

เวลาเรียน: 15–20 นาที ต้องรู้มาก่อน: Module 06 รูปแบบ: อ่าน + Code Lab + แบบฝึก

จบบทนี้แล้วคุณจะ...

  • อธิบายว่า Page Object Model (POM) คืออะไรและแก้ปัญหาอะไร
  • แยก page logic ออกจาก test logic
  • สร้าง Page class ที่มี methods สำหรับ user actions
  • รู้ว่าเมื่อไหร่ POM เกินจำเป็น (overkill)
1 · ปัญหา: Test ที่อ่านไม่รู้เรื่อง

ดู test นี้:

test('user can login and see dashboard', async ({ page }) => {
  await page.goto('/login');
  await page.locator('#email').fill('[email protected]');
  await page.locator('#password').fill('secret123');
  await page.locator('button[type="submit"]').click();
  await page.waitForURL('/dashboard');
  await expect(page.locator('.welcome-text')).toContainText('สวัสดี');
  await expect(page.locator('.stats-panel')).toBeVisible();
});

ดูเผินๆ ก็โอเค แต่พอมี 50 tests ที่ทำ login ก่อน — ถ้า login form เปลี่ยน (เช่น #email เป็น #username) ต้องแก้ 50 ที่

นี่คือปัญหาที่ POM แก้

2 · Page Object Model คืออะไร?

POM คือ design pattern ที่:

  • สร้าง class หนึ่งตัวต่อหนึ่งหน้า (หรือ component)
  • class เก็บ locators และ actions ของหน้านั้น
  • test เรียกใช้ methods แทนที่จะเขียน locator ตรงๆ

ผลลัพธ์: เมื่อ UI เปลี่ยน → แก้แค่ใน Page class ที่เดียว → test ทั้งหมดยังทำงานได้

3 · สร้าง Page Class

LoginPage

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('#email');
    this.passwordInput = page.locator('#password');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.error-message');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

DashboardPage

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

export class DashboardPage {
  readonly page: Page;
  readonly welcomeText: Locator;
  readonly statsPanel: Locator;
  readonly logoutButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.welcomeText = page.locator('.welcome-text');
    this.statsPanel = page.locator('.stats-panel');
    this.logoutButton = page.locator('[data-testid="logout"]');
  }

  async expectLoaded() {
    await expect(this.welcomeText).toBeVisible();
    await expect(this.statsPanel).toBeVisible();
  }

  async expectWelcomeMessage(name: string) {
    await expect(this.welcomeText).toContainText(`สวัสดี ${name}`);
  }

  async logout() {
    await this.logoutButton.click();
  }
}
4 · ใช้ POM ใน Test
import { test } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';

test('user can login and see dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('[email protected]', 'secret123');
  await dashboardPage.expectLoaded();
  await dashboardPage.expectWelcomeMessage('Admin');
});

test('shows error on invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('[email protected]', 'wrongpass');
  await loginPage.expectError('อีเมลหรือรหัสผ่านไม่ถูกต้อง');
});

สังเกต: test อ่านเหมือนภาษามนุษย์ — loginPage.login(), dashboardPage.expectLoaded()

5 · เมื่อไหร่ POM เกินจำเป็น?
สถานการณ์ ควรใช้ POM?
Test suite มี 20+ tests ที่ใช้หน้าเดิมซ้ำๆ ใช่
UI เปลี่ยนบ่อย ใช่ — แก้ที่เดียว
ทีมมีหลายคนเขียน test ใช่ — มี contract ชัดเจน
Test มีแค่ 3-5 ตัว อาจไม่จำเป็น
Prototype หรือ POC ที่จะทิ้ง ไม่จำเป็น
One-off test สำหรับ debug ไม่จำเป็น

กฎ: ถ้าเห็น locator เดิมใน 3 tests ขึ้นไป → ถึงเวลา extract เป็น POM

แบบฝึกหัด: Refactor Inline Test ให้ใช้ POM

โจทย์: refactor test ต่อไปนี้ให้ใช้ POM

// ก่อน refactor — inline locators ทั้งหมด
test('add item to cart', async ({ page }) => {
  await page.goto('/products');
  await page.locator('.product-card').first().locator('.add-to-cart-btn').click();
  await expect(page.locator('.cart-count')).toHaveText('1');
  await page.locator('.cart-icon').click();
  await expect(page.locator('.cart-item')).toHaveCount(1);
  await expect(page.locator('.cart-total')).toContainText('฿');
});
ดูคำตอบแนะนำ
// pages/products.page.ts
import { Page, Locator, expect } from '@playwright/test';

export class ProductsPage {
  readonly page: Page;
  readonly productCards: Locator;
  readonly cartCount: Locator;
  readonly cartIcon: Locator;

  constructor(page: Page) {
    this.page = page;
    this.productCards = page.locator('.product-card');
    this.cartCount = page.locator('.cart-count');
    this.cartIcon = page.locator('.cart-icon');
  }

  async goto() {
    await this.page.goto('/products');
  }

  async addFirstProductToCart() {
    await this.productCards.first().locator('.add-to-cart-btn').click();
  }

  async expectCartCount(count: number) {
    await expect(this.cartCount).toHaveText(String(count));
  }

  async openCart() {
    await this.cartIcon.click();
  }
}

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

export class CartPage {
  readonly page: Page;
  readonly cartItems: Locator;
  readonly cartTotal: Locator;

  constructor(page: Page) {
    this.page = page;
    this.cartItems = page.locator('.cart-item');
    this.cartTotal = page.locator('.cart-total');
  }

  async expectItemCount(count: number) {
    await expect(this.cartItems).toHaveCount(count);
  }

  async expectTotalVisible() {
    await expect(this.cartTotal).toContainText('฿');
  }
}

// tests/cart.spec.ts
import { test } from '@playwright/test';
import { ProductsPage } from '../pages/products.page';
import { CartPage } from '../pages/cart.page';

test('add item to cart', async ({ page }) => {
  const productsPage = new ProductsPage(page);
  const cartPage = new CartPage(page);

  await productsPage.goto();
  await productsPage.addFirstProductToCart();
  await productsPage.expectCartCount(1);
  await productsPage.openCart();
  await cartPage.expectItemCount(1);
  await cartPage.expectTotalVisible();
});

สิ่งที่ต้องจำ

  • POM แยก "อะไรอยู่ตรงไหน" ออกจาก "ทดสอบอะไร" — UI เปลี่ยน แก้แค่ที่เดียว
  • Page class เก็บ locators + action methods + assertion methods
  • Test เรียก methods ที่อ่านเข้าใจง่าย เช่น loginPage.login()
  • ไม่ต้องใช้ POM ทุกกรณี — ถ้า test น้อยและง่าย inline ก็โอเค
  • กฎ: เห็น locator ซ้ำ 3 ครั้ง → extract เป็น POM