Khi nhiều test đều nhập email, mật khẩu và bấm Đăng nhập, cùng một nhóm locator dễ bị lặp ở khắp nơi. Page Object Model (POM) tổ chức các thao tác của một trang hoặc một phần giao diện vào một class. Test gọi hành động có ý nghĩa và tự kiểm tra kết quả mong đợi.
Ví dụ nhỏ: trang đăng nhập
Ví dụ dưới đây, màn hình Đăng nhập được miêu tả bằng 1 class có tên là LoginPage, nó bao gồm:
- Các thành phần locator trên màn hình (như trường email, trường password, nút login…)
- Các thao tác mà màn hình đó cung cấp (ví dụ như login(), open())
import { Locator, Page } from "@playwright/test";
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly loginButton: Locator;
readonly welcomeHeading: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Mật khẩu");
this.loginButton = page.getByRole("button", { name: "Đăng nhập" });
this.welcomeHeading = page.getByRole("heading");
}
async open(): Promise<void> {
await this.page.goto("/login");
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}from playwright.sync_api import Locator, Page
class LoginPage:
def __init__(self, page: Page) -> None:
self.page = page
self._email_input: Locator = page.get_by_label("Email")
self._password_input: Locator = page.get_by_label("Mật khẩu")
self._login_button: Locator = page.get_by_role(
"button", name="Đăng nhập"
)
self.welcome_heading: Locator = page.get_by_role("heading")
def open(self) -> None:
self.page.goto("/login")
def login(self, email: str, password: str) -> None:
self._email_input.fill(email)
self._password_input.fill(password)
self._login_button.click()Page là tab trình duyệt Playwright được truyền qua constructor. Các locator được lưu thành biến riêng và khởi tạo một lần trong constructor, nên method login() chỉ còn mô tả các bước nghiệp vụ.
Dưới đây là file test case sẽ gọi đến file LoginPage bên trên:
import { test, expect } from "@playwright/test";
import { LoginPage } from "./login-page";
test("đăng nhập thành công", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.open();
await loginPage.login("an@example.com", "secret123");
await expect(loginPage.welcomeHeading).toHaveText("Xin chào An");
});from playwright.sync_api import Page, expect
from login_page import LoginPage
def test_login_successfully(page: Page) -> None:
login_page = LoginPage(page)
login_page.open()
login_page.login("an@example.com", "secret123")
expect(login_page.welcome_heading).to_have_text("Xin chào An")Test vẫn thể hiện điều cần kiểm tra qua expect(); page object mô tả cách thao tác. Khi tên hoặc cấu trúc của nút đổi, bạn chỉnh locator ở LoginPage và chạy lại các test liên quan. Code gọn gàng, dễ maintain.
Page object thường chứa gì?
- Constructor nhận
Pagehoặc vùng giao diện liên quan. - Locator ổn định và method thao tác như
login(),search(). - Các method đọc thông tin trên trang, thao tác tương tác với trang
Các page có thể kế thừa 1 BasePage nếu cần
Khi nhiều page cùng cần một số hành vi như mở đường dẫn, đọc tiêu đề hoặc chờ trang tải xong, có thể đặt phần dùng chung vào BasePage. Các page cụ thể sẽ kế thừa class đó:
BasePage
import { Page } from "@playwright/test";
export class BasePage {
constructor(protected readonly page: Page) {}
async openPath(path: string): Promise<void> {
await this.page.goto(path);
}
}from playwright.sync_api import Page
class BasePage:
def __init__(self, page: Page) -> None:
self.page = page
def open_path(self, path: str) -> None:
self.page.goto(path)ProductsPage
import { Locator, Page } from "@playwright/test";
import { BasePage } from "./base-page";
export class ProductsPage extends BasePage {
private readonly searchInput: Locator;
constructor(page: Page) {
super(page);
this.searchInput = page.getByPlaceholder("Tìm sản phẩm");
}
async open(): Promise<void> {
await this.openPath("/products");
}
async search(keyword: string): Promise<void> {
await this.searchInput.fill(keyword);
await this.searchInput.press("Enter");
}
}from playwright.sync_api import Locator, Page
from base_page import BasePage
class ProductsPage(BasePage):
def __init__(self, page: Page) -> None:
super().__init__(page)
self._search_input: Locator = page.get_by_placeholder(
"Tìm sản phẩm"
)
def open(self) -> None:
self.open_path("/products")
def search(self, keyword: str) -> None:
self._search_input.fill(keyword)
self._search_input.press("Enter")ProductsPage kế thừa page và openPath()/open_path() từ BasePage. Lệnh super(page) gọi constructor của class cha; page con chỉ cần khai báo locator và hành vi riêng.
BasePage khi thật sự có logic chung. Một BasePage quá lớn chứa đủ loại locator và method sẽ khiến các page con phụ thuộc lẫn nhau và khó bảo trì.Sử dụng Composition để khởi tạo menu xuyên suốt các page
Menu, header, sidebar hoặc modal thường xuất hiện trên nhiều trang nhưng không phải là “class cha” của các trang đó. Với composition, ta tách chúng thành component object rồi khởi tạo component bên trong từng page object.
MainMenu
import { Locator, Page } from "@playwright/test";
export class MainMenu {
private readonly homeLink: Locator;
private readonly profileLink: Locator;
constructor(page: Page) {
this.homeLink = page.getByRole("link", { name: "Trang chủ" });
this.profileLink = page.getByRole("link", { name: "Hồ sơ" });
}
async openHome(): Promise<void> {
await this.homeLink.click();
}
async openProfile(): Promise<void> {
await this.profileLink.click();
}
}from playwright.sync_api import Locator, Page
class MainMenu:
def __init__(self, page: Page) -> None:
self._home_link: Locator = page.get_by_role(
"link", name="Trang chủ"
)
self._profile_link: Locator = page.get_by_role(
"link", name="Hồ sơ"
)
def open_home(self) -> None:
self._home_link.click()
def open_profile(self) -> None:
self._profile_link.click()HomePage
import { Page } from "@playwright/test";
import { MainMenu } from "./main-menu";
export class HomePage {
readonly menu: MainMenu;
constructor(page: Page) {
this.menu = new MainMenu(page);
}
}from playwright.sync_api import Page
from main_menu import MainMenu
class HomePage:
def __init__(self, page: Page) -> None:
self.menu = MainMenu(page)ArticlePage
import { Page } from "@playwright/test";
import { MainMenu } from "./main-menu";
export class ArticlePage {
readonly menu: MainMenu;
constructor(page: Page) {
this.menu = new MainMenu(page);
}
}from playwright.sync_api import Page
from main_menu import MainMenu
class ArticlePage:
def __init__(self, page: Page) -> None:
self.menu = MainMenu(page)Cả HomePage và ArticlePage đều có một MainMenu. Test có thể gọi homePage.menu.openProfile() hoặc articlePage.menu.openHome() mà không lặp lại locator của menu trên từng page.
ProductsPage là một BasePage. Composition diễn tả quan hệ “has-a”: mỗi page có một
MainMenu.Ưu và nhược điểm của POM
| Ưu điểm | Nhược điểm / rủi ro |
|---|---|
| Giảm lặp locator và thao tác | Phải tạo thêm class và file, có thể rườm rà với test nhỏ |
| Tập trung phần code cần sửa khi UI thay đổi | Page object quá lớn khó bảo trì |
| Test đọc gần với hành vi người dùng | Ẩn nhiều bước khiến khó thấy test thật sự làm gì |
| Tái sử dụng thao tác chung | Một method chung sửa sai có thể ảnh hưởng nhiều test |




