Page Object Model (POM)
จบบทนี้แล้วคุณจะ...
- อธิบายว่า 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