Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn vừa viết xong 40 test case UI cho một sàn thương mại điện tử. Tất cả đều chạy xanh, sếp gật gù, bạn thấy mình thật giỏi. Rồi một sáng thứ Hai, team frontend đổi nút "Đăng nhập" từ id="btn-login" sang id="login-submit". Chỉ một dòng thay đổi nhỏ xíu bên họ, nhưng bên bạn: 27 test case đỏ lòm, và bạn phải mở 27 file, tìm 27 chỗ, sửa 27 lần. Mất cả buổi sáng. Đó chính là cơn ác mộng mà Page Object Model (POM) sinh ra để giải quyết.
POM không phải là một thư viện, không phải một công cụ bạn cài đặt bằng pip install. Nó là một design pattern — một cách tổ chức code test. Và trong toàn bộ thế giới UI automation, đây gần như là pattern được áp dụng rộng rãi nhất, được coi là kiến thức nền tảng bắt buộc. Nếu bạn đi phỏng vấn vị trí Automation Tester ở FPT Software, KMS Technology, hay bất kỳ công ty outsource nào tại Việt Nam, câu hỏi "POM là gì và tại sao dùng nó?" gần như chắc chắn sẽ xuất hiện.
Trong bài trước bạn đã học về Locators và Wait Strategies — đó là những viên gạch. Bài này dạy bạn cách xếp gạch thành nhà: tổ chức locators và hành động thành một kiến trúc bền vững, dễ bảo trì. Đây là bước ngoặt phân biệt một người "biết viết Selenium" với một người "viết được test framework chuyên nghiệp".
Khái niệm cốt lõi
POM là gì?
Page Object Model là mô hình mà trong đó mỗi trang (page) hoặc mỗi thành phần giao diện (component) của ứng dụng được biểu diễn bằng một class riêng. Class đó đóng gói (encapsulate) hai thứ:
- Locators — cách tìm các phần tử trên trang (nút, ô input, link...).
- Actions/Methods — các hành động người dùng có thể làm trên trang đó (nhập text, click nút, đọc thông báo lỗi...).
LoginPage class. Bên trong nó biết ô username nằm ở đâu, ô password nằm ở đâu, nút submit nằm ở đâu — và nó cung cấp những method như login(username, password) để test case gọi.Điểm mấu chốt của triết lý POM: test case KHÔNG bao giờ được biết đến locator. Test case chỉ nói "tôi muốn đăng nhập với tài khoản này", còn đăng nhập bằng cách nào, phần tử ở đâu là việc của Page Object.
Tách biệt hai tầng: "cái gì" và "như thế nào"
POM tạo ra một ranh giới rõ ràng:
- Tầng test (test layer) mô tả nghiệp vụ: "Người dùng đăng nhập, thêm sản phẩm vào giỏ, kiểm tra tổng tiền". Đây là ngôn ngữ business, người không code cũng đọc hiểu được.
- Tầng page object (page layer) mô tả kỹ thuật: locator nào, click ra sao, chờ phần tử thế nào.
Vì sao POM lại đáng giá đến vậy?
DRY (Don't Repeat Yourself) — Đừng lặp lại chính mình. Đây là lý do quan trọng nhất. Locator của nút "Đăng nhập" chỉ được viết đúng một lần trong LoginPage. Có 50 test case dùng đến nút đó cũng không sao — khi nút đổi, bạn sửa một chỗ duy nhất. Quay lại câu chuyện đầu bài: nếu đã dùng POM, cả buổi sáng khổ sở kia rút gọn xuống còn một dòng sửa và 30 giây.
Dễ đọc. So sánh hai đoạn code:
// Không POM — test đầy chi tiết kỹ thuật, khó đọc
driver.findElement(By.id("username")).sendKeys("nguyenvana");
driver.findElement(By.id("password")).sendKeys("secret123");
driver.findElement(By.cssSelector("button.submit")).click();// Có POM — test đọc như một câu tiếng Anh
loginPage.login("nguyenvana", "secret123");
Tái sử dụng. Method login() viết một lần, dùng cho hàng chục kịch bản khác nhau (login thành công, login sai pass, login rồi mua hàng...).
Dễ mở rộng và bảo trì. Một người mới vào team, muốn biết trang giỏ hàng làm được gì? Họ mở CartPage và đọc danh sách method — rõ ràng như một bản hợp đồng.
Tình huống thực tế
Ví dụ 1 — Tiki và bài học "sửa một chỗ thay vì trăm chỗ"
Một team QA tại một công ty e-commerce cỡ Tiki quản lý bộ regression suite gồm khoảng 300 test case UI. Ban đầu, do dự án gấp, họ viết theo kiểu "script tuyến tính" — mỗi test case tự gọi driver.findElement() trực tiếp. Locator của thanh tìm kiếm (id="keyword") xuất hiện rải rác trong khoảng 80 test case liên quan đến tìm kiếm và duyệt sản phẩm.
Sau một đợt redesign, team frontend đổi id="keyword" thành data-testid="search-input". Kết quả: 80 test đỏ. Bạn QA phải mở từng file, dùng Find & Replace toàn dự án — nhưng vì có vài chỗ locator viết hơi khác nhau (chỗ dùng By.id, chỗ dùng By.name), Find & Replace không bắt hết, sót lại vài chỗ, và họ mất gần hai ngày để dọn sạch.
Sau sự cố, team refactor toàn bộ sang POM. Họ tạo SearchComponent chứa đúng một locator cho ô tìm kiếm. Sáu tháng sau, frontend lại đổi locator lần nữa. Lần này? Một dòng sửa, năm phút, xong. Bài học: chi phí bỏ ra để dựng POM ban đầu tưởng là "chậm", nhưng nó là khoản đầu tư có lãi kép — càng nhiều test, càng nhiều lần thay đổi giao diện, POM càng tiết kiệm.
Ví dụ 2 — Startup fintech và cái bẫy "God Page"
Một startup fintech ở TP.HCM (tạm gọi là PayFast) xây app ví điện tử. Bạn dev SDET mới ra trường rất chăm, đã áp dụng POM ngay từ đầu — điểm cộng lớn. Nhưng cậu ấy phạm một sai lầm phổ biến: gom toàn bộ ứng dụng vào một class khổng lồ tên AppPage, dài 2.000 dòng, chứa locator của trang đăng nhập, trang chuyển tiền, trang lịch sử giao dịch, trang cài đặt — tất cả trong một chỗ.
Kết quả là class đó trở thành một "God Object": ai sửa gì cũng đụng vào file đó, merge conflict liên tục khi ba người cùng làm, và không ai dám refactor vì sợ vỡ chỗ khác. Đây là anti-pattern có tên God Page.
Team lead yêu cầu tách ra: LoginPage, TransferPage, TransactionHistoryPage, SettingsPage. Nguyên tắc: một page thật của ứng dụng = một class, hoặc với các thành phần dùng lại nhiều nơi (như thanh menu, popup xác nhận OTP) thì tách thành component object riêng. Sau khi tách, mỗi file gọn dưới 200 dòng, ba người làm song song mà hầu như không đụng nhau. Bài học: POM đúng nghĩa là chia nhỏ hợp lý, không phải nhét mọi thứ vào một class chỉ vì "đã dùng class rồi".
Ví dụ 3 — Team outsource và nguyên tắc "Page Object không chứa assertion"
Một team tại một công ty outsource lớn (kiểu KMS hay NashTech) làm dự án cho khách Nhật. Reviewer người Nhật rất khó tính về code quality. Bạn junior viết LoginPage như sau: bên trong method login(), cậu ấy nhét luôn cả Assert.assertTrue(dashboard.isDisplayed()) để "kiểm tra login thành công".
Reviewer từ chối (reject) PR với lý do: Page Object không nên chứa assertion. Vì sao? Vì cùng một method login() sẽ được dùng cho cả hai kịch bản: login thành công (kỳ vọng vào được dashboard) và login thất bại (kỳ vọng ở lại trang login với thông báo lỗi). Nếu assertion nằm trong Page Object, method login() chỉ phục vụ được một kịch bản. Assertion là việc của test case, còn Page Object chỉ cung cấp thông tin (ví dụ trả về text lỗi qua getErrorMessage()) để test case tự quyết định đúng/sai.
Bài học: Page Object trả lời câu hỏi "trang này làm được gì và cho biết gì", còn test case trả lời câu hỏi "kết quả đó đúng hay sai". Đừng trộn lẫn hai vai trò.
Hướng dẫn từng bước
Ta sẽ xây một LoginPage hoàn chỉnh bằng Java + Selenium để bạn thấy đủ các mảnh ghép. Cách làm này áp dụng tương tự với Python, C#, JavaScript.
Bước 1 — Tạo class và nhận driver qua constructor.
Page Object cần một tham chiếu tới trình duyệt để thao tác. Ta truyền WebDriver vào qua constructor.
public class LoginPage {
private final WebDriver driver;
private final WebDriverWait wait; public LoginPage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
}
Bước 2 — Khai báo locators ở một chỗ, để private.
Locator là chi tiết nội bộ, không cho bên ngoài đụng vào. Dùng By object, không dùng WebElement trực tiếp (sẽ nói ở phần lỗi thường gặp tại sao).
private final By usernameField = By.id("username");
private final By passwordField = By.id("password");
private final By loginButton = By.cssSelector("button[type='submit']");
private final By errorMessage = By.className("alert-danger");
Bước 3 — Viết các method hành động, mô tả theo ngôn ngữ nghiệp vụ.
Method nên đọc như hành động của người dùng thật. Mỗi hành động tự lo phần chờ (wait) cần thiết.
public void enterUsername(String username) {
wait.until(ExpectedConditions.visibilityOfElementLocated(usernameField))
.sendKeys(username);
} public void enterPassword(String password) {
driver.findElement(passwordField).sendKeys(password);
}
public void clickLogin() {
driver.findElement(loginButton).click();
}
public String getErrorMessage() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(errorMessage))
.getText();
}
Bước 4 — Tạo method nghiệp vụ tổng hợp (business method).
Gom các bước lẻ thành một hành động cấp cao để test gọi cho gọn.
public DashboardPage loginAs(String username, String password) {
enterUsername(username);
enterPassword(password);
clickLogin();
return new DashboardPage(driver); // trả về page kế tiếp
}
Lưu ý mẹo hay: khi login thành công dẫn sang trang khác, method trả về Page Object của trang tiếp theo. Đây gọi là fluent/chaining pattern — giúp test đọc mượt như một dòng chảy.
Bước 5 — Viết test case chỉ dùng Page Object, không đụng locator.
@Test
public void loginThanhCong_dẫnTớiDashboard() {
LoginPage loginPage = new LoginPage(driver);
DashboardPage dashboard = loginPage.loginAs("nguyenvana", "matkhau123");
Assert.assertTrue(dashboard.isUserLoggedIn());
}@Test
public void loginSaiMatKhau_hiệnThôngBáoLỗi() {
LoginPage loginPage = new LoginPage(driver);
loginPage.enterUsername("nguyenvana");
loginPage.enterPassword("saibetnhè");
loginPage.clickLogin();
Assert.assertEquals(loginPage.getErrorMessage(), "Sai tài khoản hoặc mật khẩu");
}
Nhìn hai test case này: chúng đọc như tiếng Việt, không có By.id nào lọt vào, và assertion nằm đúng chỗ — trong test, không trong page. Đó là POM chuẩn.
Lỗi thường gặp & mẹo
Lỗi 1 — Nhét assertion vào Page Object. Như ví dụ 3 đã nói: Page Object cung cấp dữ liệu, test case phán xét. Ngoại lệ được chấp nhận là các "self-check" xác nhận trang đã load đúng (ví dụ chờ URL đổi), nhưng assertion nghiệp vụ luôn để ở test.
Lỗi 2 — Lưu WebElement làm biến thành viên thay vì By. Nhiều người viết private WebElement loginButton = driver.findElement(...). Nguy hiểm: element được tìm ngay lúc khởi tạo class, nhưng lúc đó trang có thể chưa load xong, dẫn tới NoSuchElementException, hoặc gặp StaleElementReferenceException khi DOM thay đổi. Hãy lưu By (chỉ là "công thức tìm"), và findElement ngay tại thời điểm dùng. (Selenium có @FindBy + PageFactory giải quyết bằng lazy proxy, nhưng lưu By thủ công là cách an toàn và dễ hiểu nhất cho người mới.)
Lỗi 3 — God Page. Một class ôm cả app. Chia theo trang thật và theo component tái sử dụng (header, footer, modal OTP...).
Lỗi 4 — Method quá vụn hoặc quá thô. Đừng tạo method clickButtonById(id) — như thế locator lại lộ ra ngoài, mất ý nghĩa POM. Ngược lại, đừng gộp cả một kịch bản 10 bước vào một method cứng nhắc không tái sử dụng được. Cân bằng: method nên tương ứng với một hành động người dùng có ý nghĩa.
Lỗi 5 — Lặp lại wait ở mọi nơi. Nếu mọi method đều wait.until(...) giống nhau, hãy tạo một BasePage chứa các helper dùng chung (click(By locator), type(By locator, String text)) và cho các Page kế thừa. Điều này chống lặp ngay trong chính lớp page.
Mẹo — Dùng BasePage làm nền. Tất cả Page Object kế thừa từ BasePage chứa driver, wait, và các thao tác cơ bản có sẵn wait. Vừa DRY, vừa nhất quán chiến lược chờ trên toàn framework.
Mẹo — Đặt tên method theo góc nhìn người dùng. loginAs(), searchFor(), addToCart() — không phải sendKeysToUsername(). Tên tốt khiến test tự tài liệu hóa.
Bài tập thực hành
- Dựng LoginPage của riêng bạn. Chọn một trang login công khai (ví dụ
the-internet.herokuapp.com/loginhoặcsaucedemo.com). Viết classLoginPagevới đủ locators, methodloginAs(), vàgetErrorMessage(). Viết hai test: một login đúng, một login sai — và bảo đảm test không chứa bất kỳBynào.
- Tách component. Trên trang sau khi login (danh sách sản phẩm của saucedemo), tách phần header (giỏ hàng, menu) thành một
HeaderComponentriêng, tái sử dụng được ở nhiều trang.
- Tự soi lỗi God Page. Nhìn lại code bài 1, thử tưởng tượng nếu ứng dụng có 20 trang mà bạn gom hết vào một class — viết ra 3 vấn đề cụ thể sẽ gặp. Sau đó vẽ sơ đồ chia trang hợp lý.
- Áp dụng chaining. Sửa
loginAs()để trả vềInventoryPage, rồi viết một test một dòng:loginPage.loginAs(user, pass).addToCart("Sauce Labs Backpack");.
Tóm tắt
Page Object Model là pattern nền tảng của UI automation: mỗi trang/component là một class, đóng gói locators và actions bên trong. Test case chỉ nói muốn làm gì, còn làm bằng cách nào thuộc về Page Object. Lợi ích cốt lõi là DRY — locator viết một lần, sửa một chỗ — cùng với khả năng đọc, tái sử dụng và bảo trì vượt trội.
Nhớ ba nguyên tắc vàng qua ba câu chuyện thực tế: (1) sửa locator ở một nơi duy nhất là sức mạnh lớn nhất của POM (Tiki); (2) chia trang hợp lý, tránh God Page (PayFast); (3) assertion thuộc về test case, không thuộc Page Object (team outsource). Tránh các bẫy: lưu WebElement thay vì By, method làm lộ locator, và wait lặp lại — hãy dùng BasePage để chống lặp.
Nắm chắc POM, bạn đã có bộ khung để test không chỉ chạy được mà còn sống lâu. Ở các bài tiếp theo — Data-Driven và Keyword-Driven Testing — bạn sẽ thấy POM chính là lớp nền để những kỹ thuật đó đứng lên.